diff --git a/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml b/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml new file mode 100644 index 0000000000..71c953a935 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.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/archived/bug-fix/2026-08-10-web-favicon-dark-mode.md +2026-08-10-web-favicon-dark-mode.md: 22e1d063a72b177e0e5c11f4bcfcbc233a81fdfc +2026-08-10-web-favicon-dark-mode.zh.md: dff90567337c6968220091c16c6cd55cf7dad2d1 diff --git a/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.md b/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.md new file mode 100644 index 0000000000..22e1d063a7 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.md @@ -0,0 +1,26 @@ +# Agent Note: Web favicon follows the color scheme + +Status: implemented +Archived: 2026-08-10 + +English | [中文](2026-08-10-web-favicon-dark-mode.zh.md) + +## Problem + +`apps/web/public/favicon.svg` paints the DeepSeek mark solid black (`fill="#000"`), and `index.html` declares only that single SVG icon. Under an OS or browser dark color scheme the tab strip is dark too, so the black mark is effectively invisible. Safari versions before 26 do not render SVG favicons, so their users get no tab icon in any scheme. + +## Decision + +The favicon stays one file and adapts through the browser's own color-scheme signal: `favicon.svg` embeds `@media (prefers-color-scheme: dark) { path { fill: #fff } }`, switching the mark to white under a dark scheme while the light scheme keeps black. `index.html` and `manifest.webmanifest` also declare a 32×32 PNG fallback (`favicon-32x32.png`, DeepSeek brand blue `#4D6BFE`) that Safari versions before 26 render and that stays visible on both light and dark tab strips, extending the [web-install-manifest decision](../feature/2026-08-06-web-install-manifest.md). + +The theme signal is the OS/browser scheme, not the GUI's in-app `dsh.theme` toggle: the favicon lives in browser chrome, whose background follows the browser scheme, so `prefers-color-scheme` is the correct semantic and needs no JavaScript. Known browser quirks — Chromium may not repaint the tab icon until reload after a scheme switch, and Safari versions before 26 ignore the SVG variant — are accepted and the PNG fallback covers the older-Safari case. + +## Alternatives considered + +- **A second `` pointing at a separate dark SVG.** Rejected: the same scheme semantics with two files to keep in sync, and no benefit over the in-file media query. +- **A theme-presenter that swaps the icon href on `theme/change`.** Rejected: it would follow the in-app toggle rather than the browser scheme that actually colors the tab strip, and it adds client code and a presenter for a chrome asset. +- **No PNG fallback.** Rejected: Safari versions before 26 never render SVG favicons, so the fallback is the only way those versions get a tab icon at all. + +## Consequences + +Light scheme still shows the black mark, dark scheme shows white, and Safari versions before 26 show the blue PNG in both. `apps/web/tests/pwa-manifest.e2e.ts` pins the PNG link and its order before the SVG, both manifest icons, the shipped PNG's format and dimensions, and the dark media query inside the shipped SVG. The Chromium repaint quirk remains a browser behavior the app cannot fix. diff --git a/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md b/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md new file mode 100644 index 0000000000..dff9056733 --- /dev/null +++ b/.agents/notes/archived/bug-fix/2026-08-10-web-favicon-dark-mode.zh.md @@ -0,0 +1,26 @@ +# Agent Note: 网页图标随配色方案切换 + +Status: implemented +Archived: 2026-08-10 + +[English](2026-08-10-web-favicon-dark-mode.md) | 中文 + +## 问题 + +`apps/web/public/favicon.svg` 把 DeepSeek 图标绘制为纯黑色(`fill="#000"`),而 `index.html` 只声明了这一个 SVG 图标。当操作系统或浏览器处于暗色配色方案时,标签栏同样是深色,黑色图标实际上不可见。Safari 26 之前的版本不渲染 SVG favicon,因此这些版本的 Safari 用户无论何种配色方案都看不到标签页图标。 + +## 决策 + +favicon 保持单一文件,并通过浏览器自身的配色方案信号自适应:`favicon.svg` 内嵌 `@media (prefers-color-scheme: dark) { path { fill: #fff } }`,在暗色方案下把图标切换为白色,浅色方案保持黑色。`index.html` 与 `manifest.webmanifest` 同时声明 32×32 PNG 兜底(`favicon-32x32.png`,DeepSeek 品牌蓝 `#4D6BFE`),Safari 26 之前的版本会渲染该 PNG,且它在浅色与深色标签栏上都清晰可见;这是对 [Web 安装 manifest 决策](../feature/2026-08-06-web-install-manifest.md) 的扩展。 + +主题信号取操作系统/浏览器方案,而不是 GUI 应用内 `dsh.theme` 开关:favicon 位于浏览器 chrome 中,其背景跟随浏览器方案,因此 `prefers-color-scheme` 是正确语义,无需任何 JavaScript。已知的浏览器怪癖——Chromium 在切换方案后可能要到刷新页面才重绘标签图标,Safari 26 之前的版本忽略 SVG 变体——均被接受,旧版 Safari 场景由 PNG 兜底覆盖。 + +## 曾考虑的替代方案 + +- **新增指向独立暗色 SVG 的第二个 ``。** 不予采纳:语义相同却要多维护一个文件,相比文件内媒体查询没有任何收益。 +- **由主题 presenter 在 `theme/change` 时替换图标 href。** 不予采纳:它会跟随应用内开关,而不是真正决定标签栏颜色的浏览器方案,并且为一个 chrome 资源引入客户端代码和 presenter。 +- **不提供 PNG 兜底。** 不予采纳:Safari 26 之前的版本从不渲染 SVG favicon,兜底是这些版本获得标签图标的唯一途径。 + +## 后果 + +浅色方案仍显示黑色图标,暗色方案显示白色,Safari 26 之前的版本两种方案都显示蓝色 PNG。`apps/web/tests/pwa-manifest.e2e.ts` 固定断言 PNG 链接及其位于 SVG 之前的顺序、manifest 中的两个图标、交付 PNG 的格式与尺寸,以及交付 SVG 内部的暗色媒体查询。Chromium 的重绘怪癖仍是浏览器行为,应用无法修复。 diff --git a/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.i18n.yaml new file mode 100644 index 0000000000..d07841d540 --- /dev/null +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.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/archived/feature/2026-08-08-dsh-run-headless-command.md +2026-08-08-dsh-run-headless-command.md: ce9cff965192357022c49655983fe6ff8d554b9f +2026-08-08-dsh-run-headless-command.zh.md: 0484c069ed6365235e4616542a4fb3d5ceb2d880 diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md similarity index 84% rename from .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md rename to .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md index ed095f4077..ce9cff9651 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.md @@ -1,9 +1,12 @@ # Agent Note: `dsh run` owns one-shot headless execution Status: implemented +Archived: 2026-08-10 English | [中文](2026-08-08-dsh-run-headless-command.zh.md) +> **Superseded command grammar.** [Apps now own their command lines](../architecture/2026-08-06-app-owned-command-line.md): the headless startup row parses the task from `dsh --profile headless `, and the launcher no longer has a `run` invocation or patches task text into rows. This note remains the rejected launcher-owned design context; the direct execution and completion contract it selected remains current in [headless is a direct core entry point](../architecture/2026-08-09-headless-direct-core-entry-point.md). + ## Problem Generic profile boot and one-shot task execution have different lifecycle contracts. A root grammar that accepts optional task text makes one argv shape mean either a long-lived process or a terminating task according to a plugin row discovered only after composition. It also exposes a profile implementation detail as the primary user command and gives custom profiles no explicit one-shot entry. diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md similarity index 85% rename from .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md rename to .agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md index 89d54e3557..0484c069ed 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.zh.md +++ b/.agents/notes/archived/feature/2026-08-08-dsh-run-headless-command.zh.md @@ -1,9 +1,12 @@ # Agent Note: `dsh run` 负责一次性 headless 执行 Status: implemented +Archived: 2026-08-10 [English](2026-08-08-dsh-run-headless-command.md) | 中文 +> **命令语法已被取代。** [应用现在持有自己的命令行](../architecture/2026-08-06-app-owned-command-line.md):headless 启动行从 `dsh --profile headless ` 解析任务,启动器不再包含 `run` 调用,也不再把任务文本 patch 进配置行。本笔记保留被否决的启动器持有设计背景;它选定的直接执行与完成约定仍由 [headless 是直接 core 入口](../architecture/2026-08-09-headless-direct-core-entry-point.md)持有。 + ## 问题 通用 profile 启动与一次性任务执行具有不同的生命周期约定。若根语法接受可选任务文本,同一种 argv 形态会表示常驻进程或终止式任务,具体含义取决于组合完成后才发现的插件配置行。它还会把 profile 实现细节暴露成主要用户命令,并使自定义 profile 缺少明确的一次性入口。 diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 638d87373d..a8377c346f 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -94,6 +94,9 @@ "bug-fix/2026-08-03-tui-long-session-render-costs.i18n.yaml": "sha256:f65f7bf8fc84c7a1f022ee393c8d969c06d9bde8bed3a0206de86fb35b246ac6", "bug-fix/2026-08-03-tui-long-session-render-costs.md": "sha256:6ecf2ef831f527f361ade18a882d79bc6eccf15cc676d05728e7753f41cde051", "bug-fix/2026-08-03-tui-long-session-render-costs.zh.md": "sha256:5f44e707b332e13fa06d625212173ea055c1c3c0aee60888435a0ff099ec6037", + "bug-fix/2026-08-10-web-favicon-dark-mode.i18n.yaml": "sha256:859c4399f9a017a68ba89552fdafa05e73c0599d94cee9551c84ea5b749a14f3", + "bug-fix/2026-08-10-web-favicon-dark-mode.md": "sha256:4d17e247abd76ae3aed5fb4e075fd66a2838292f89f7021c82a79fe37ed905e6", + "bug-fix/2026-08-10-web-favicon-dark-mode.zh.md": "sha256:7bbff8a3b7061c127afcc75cd2a8043b02a999b78c0180edd8f7e4807fcfe71d", "feature/2026-06-14-acp-agent-client-protocol.i18n.yaml": "sha256:006795baa43ae962a8d125cc0f1e9f134bc2ee9fb758b6e7669e3fa0126e1918", "feature/2026-06-14-acp-agent-client-protocol.md": "sha256:6828c0af74bb3fb96206ca6b21c0e56a000b50e4744aad4bc2c05092f3a5a31b", "feature/2026-06-14-acp-agent-client-protocol.zh.md": "sha256:ba104e841a1fb84edbd3b6c8119d50445b7785255a7a8d13bb9ac8a2cb4d2e69", @@ -253,6 +256,9 @@ "feature/2026-07-31-web-cards-toolrow.i18n.yaml": "sha256:f9a6ab72a77934cdcc02167c7313f08d7e9925362017b34bed7ad56c8c70fbaa", "feature/2026-07-31-web-cards-toolrow.md": "sha256:5058f7cec4497d1cb0a5c8e77b88fddacac6eead034f3edec88e8514919b8a3e", "feature/2026-07-31-web-cards-toolrow.zh.md": "sha256:ba84ef2e1be61211ab5ba6950b78ede3d3a979f252bc068d3e04e2c025f7bc03", + "feature/2026-08-08-dsh-run-headless-command.i18n.yaml": "sha256:1c2b4c5b61b9263b6267275d6fc69faeaad3cc887f0728a7ed4172d817af812b", + "feature/2026-08-08-dsh-run-headless-command.md": "sha256:7695fe7fd322377d5986f14e35f13337f4cd376405c758218a81230f6d182d1c", + "feature/2026-08-08-dsh-run-headless-command.zh.md": "sha256:113c14a36c64d2facc8ae46f37c7aa76359d8cacb9c18fcba26a723f15d036fb", "process/2026-06-11-doc-sync-enforcement.i18n.yaml": "sha256:33b6d5874427bd7a2bd82e7e2f4f482b12448b2464aef15a9c57975edb48554d", "process/2026-06-11-doc-sync-enforcement.md": "sha256:aa2fe83d519fc30d48dff19e596e83c8922aacc9e063e14fe2cc35b769b9100e", "process/2026-06-11-doc-sync-enforcement.zh.md": "sha256:698017bd35f030fdea3eac51df9e43138c48140f504739d687b7251d13fced2b", @@ -295,12 +301,24 @@ "process/2026-07-23-browser-demo-gif-recording.i18n.yaml": "sha256:808ccdda39e540645b440e40a2124baed737b98636265d8f6d8cf036a70f0d50", "process/2026-07-23-browser-demo-gif-recording.md": "sha256:4d3a3dc829c75b66f4f57a6a763b4b9562ce10efb90a19308142f598c5ea8524", "process/2026-07-23-browser-demo-gif-recording.zh.md": "sha256:409e5d31ea87f35c5227fcd1bd105167f580a8a24d29a223ce390b29eb31639a", + "process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml": "sha256:cbd5c32f2997713339699f4c12876b1950895fcb21594927c7509e3cb4c5b8d5", + "process/2026-07-23-personal-staging-maintenance-skills.md": "sha256:a1ef960ddc47c8bc14578d8432ed0ac4272e31178937780989e4e3b3e3f4d49f", + "process/2026-07-23-personal-staging-maintenance-skills.zh.md": "sha256:c0f3fcf0914af88cafcc7e094a2131bd1f764003defc6eaa446b743319e73ec1", "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.i18n.yaml": "sha256:89b6dc255cb0dd9d97ae6f34f37d185412d4260d7bf2f87d6b25e340f01dc26e", "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.md": "sha256:1742e09435ade4a09349c8843eb381e870a2be2d51b74449f08260422c8096b3", "process/2026-07-26-gui-pr-gif-evidence-and-assets-branch.zh.md": "sha256:220bd53a88617b09ee8970627c46540fa8951724b102acfe296a7ed7d0f7b5fe", "process/2026-07-27-wine-windows-gates-experiment.i18n.yaml": "sha256:6f4cbc12ee9cddbb297bf7e138ccabcd204f66898a0f7411b1633f03d5a9eab5", "process/2026-07-27-wine-windows-gates-experiment.md": "sha256:8d37dcdab058098c7de3da1de00ce61bef92bbc8d6ee71add959474c6fb3e936", "process/2026-07-27-wine-windows-gates-experiment.zh.md": "sha256:77fbf04df36af09e55007a93bd6b22d08ff99869efe8de3e97dac5b4701e0a9e", + "process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml": "sha256:e7d072856cd3df3b717d94647be4ea05db087b114df4b11d68a171677b94c970", + "process/2026-07-31-installer-adopts-existing-checkout.md": "sha256:d97e39d07bde30d534417a406fc6c56ee6765dbc7b671888d17ef2cac9a7c6dc", + "process/2026-07-31-installer-adopts-existing-checkout.zh.md": "sha256:ab445493c1aacf3f8dc091bdbd765ad56c551ae613e0d8156610f0c4614a7431", + "process/2026-08-04-forward-only-pr-issue-status.i18n.yaml": "sha256:af23e203a66a95674154899410e2f420d1d0685dbf856c24cfccdaa547a17925", + "process/2026-08-04-forward-only-pr-issue-status.md": "sha256:2d31077da47d95ab3ddf64d5efc6b1b8fb7c7709d39aca4a825ef9e9d382d501", + "process/2026-08-04-forward-only-pr-issue-status.zh.md": "sha256:b61f865b7a8a0ac901250a3edbb92ea73177067c4c25448c7088925c2caeccd7", + "process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml": "sha256:4c28c59d3fc323e7cd01eff31f1fe759834719c5bede1e82b39f868970bf856d", + "process/2026-08-08-review-driven-issue-lifecycle-triggers.md": "sha256:1b0514de5d030170e91e12e4d6ba788a9247f840e82700faa385a1c0c76ab857", + "process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md": "sha256:028d78d61f603d8bac64c4cce20b393a78f8e029d3bb4976e79a47ecaefa6032", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.i18n.yaml": "sha256:ad3d1263cb0051b885173bf064de62065e2c646ccaae2d7250723da3b4eab90c", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.md": "sha256:8fb061d51c8c23b47d2367814bab3623c6d5b972f38d207a273caa9030b579bd", "simplification/2026-06-20-drop-unconsumed-llm-adapter-change-event.zh.md": "sha256:2ffeaca91f82844a5616d6dcce6b4af514bb8a7c46f78e47f668b204ac6edc04", diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml b/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml similarity index 66% rename from .agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml rename to .agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml index eb11cc1b92..ecf2ec52a1 100644 --- a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.i18n.yaml +++ b/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.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/process/2026-07-23-personal-staging-maintenance-skills.md -2026-07-23-personal-staging-maintenance-skills.md: a7ccc5b1e0f13e880c58a93d2e4c2cd4f06e2a93 -2026-07-23-personal-staging-maintenance-skills.zh.md: 8593291f510352429cbb29679e7d033589312e41 +2026-07-23-personal-staging-maintenance-skills.md: 86654be1afe986107e7dae4440b497a266636012 +2026-07-23-personal-staging-maintenance-skills.zh.md: 7593a2e8cafc93df02c9418c94f84bbd3f154a0c diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md b/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md rename to .agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.md index a7ccc5b1e0..86654be1af 100644 --- a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.md +++ b/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.md @@ -1,6 +1,7 @@ # Agent Note: Personal staging maintenance skills Status: implemented +Archived: 2026-08-10 English | [中文](2026-07-23-personal-staging-maintenance-skills.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md b/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md rename to .agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.zh.md index 8593291f51..7593a2e8ca 100644 --- a/.agents/notes/implemented/process/2026-07-23-personal-staging-maintenance-skills.zh.md +++ b/.agents/notes/archived/process/2026-07-23-personal-staging-maintenance-skills.zh.md @@ -1,6 +1,7 @@ # Agent Note: 个人集成分支维护 skill Status: implemented +Archived: 2026-08-10 [English](2026-07-23-personal-staging-maintenance-skills.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml b/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml similarity index 66% rename from .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml rename to .agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml index 1c58887b2b..c71f556ed2 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.i18n.yaml +++ b/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.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/process/2026-07-31-installer-adopts-existing-checkout.md -2026-07-31-installer-adopts-existing-checkout.md: 1d45a14273095610e3ed8047da2ce9f4ca95cbb6 -2026-07-31-installer-adopts-existing-checkout.zh.md: 971bc3b389c341b314872b8e45ab20ebd2ed5b2c +2026-07-31-installer-adopts-existing-checkout.md: 08eec47a1cf1254409df93ab83ddf503b4d7d171 +2026-07-31-installer-adopts-existing-checkout.zh.md: dc3cc5ee8dbbe287860ad02f4bdba66afb4fbeaf diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md b/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md rename to .agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.md index 1d45a14273..08eec47a1c 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.md +++ b/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.md @@ -1,6 +1,7 @@ # Agent Note: the installer adopts an existing checkout into the managed layout Status: implemented +Archived: 2026-08-10 English | [中文](2026-07-31-installer-adopts-existing-checkout.zh.md) diff --git a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md b/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md rename to .agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.zh.md index 971bc3b389..dc3cc5ee8d 100644 --- a/.agents/notes/implemented/process/2026-07-31-installer-adopts-existing-checkout.zh.md +++ b/.agents/notes/archived/process/2026-07-31-installer-adopts-existing-checkout.zh.md @@ -1,6 +1,7 @@ # Agent Note: 安装器把已有检出接管进受管布局 Status: implemented +Archived: 2026-08-10 [English](2026-07-31-installer-adopts-existing-checkout.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml similarity index 68% rename from .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml rename to .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml index b8e885d109..a7df92883f 100644 --- a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml +++ b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.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/process/2026-08-04-forward-only-pr-issue-status.md -2026-08-04-forward-only-pr-issue-status.md: dd567707bc7fccd0a631943ab3ffd2838a7f2f76 -2026-08-04-forward-only-pr-issue-status.zh.md: f7fee58d6afb812f97569ae4d86c3d6504f35752 +2026-08-04-forward-only-pr-issue-status.md: 56004a39ce52c77429574f481d9945cdc4936d30 +2026-08-04-forward-only-pr-issue-status.zh.md: ee85319842d3245bdfab9668de0a42ab29597fac diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md rename to .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md index dd567707bc..56004a39ce 100644 --- a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.md +++ b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.md @@ -1,6 +1,7 @@ # Agent Note: Forward-only PR-to-Issue status projection Status: implemented +Archived: 2026-08-10 English | [中文](2026-08-04-forward-only-pr-issue-status.zh.md) diff --git a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md rename to .agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md index f7fee58d6a..ee85319842 100644 --- a/.agents/notes/implemented/process/2026-08-04-forward-only-pr-issue-status.zh.md +++ b/.agents/notes/archived/process/2026-08-04-forward-only-pr-issue-status.zh.md @@ -1,6 +1,7 @@ # Agent Note: PR 到 Issue 的状态仅向前投射 Status: implemented +Archived: 2026-08-10 [English](2026-08-04-forward-only-pr-issue-status.md) | 中文 diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml similarity index 66% rename from .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml rename to .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml index a82d54640c..4c3a8c8db5 100644 --- a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml +++ b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.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/process/2026-08-08-review-driven-issue-lifecycle-triggers.md -2026-08-08-review-driven-issue-lifecycle-triggers.md: 8a2d48ee23da4c20bb832ae0109e2ea9912dac83 -2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 004739ff471815b0fe12e111eba0ec7aaaef9507 +2026-08-08-review-driven-issue-lifecycle-triggers.md: 444927968912d93f473e27ae8576e8371b9c287c +2026-08-08-review-driven-issue-lifecycle-triggers.zh.md: 6e00e2a936b6421824743e779756011fcd4a1c9e diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md rename to .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md index 8a2d48ee23..4449279689 100644 --- a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.md +++ b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.md @@ -1,6 +1,7 @@ # Agent Note: Review-driven Issue lifecycle triggers Status: implemented +Archived: 2026-08-10 English | [中文](2026-08-08-review-driven-issue-lifecycle-triggers.zh.md) diff --git a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md similarity index 99% rename from .agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md rename to .agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md index 004739ff47..6e00e2a936 100644 --- a/.agents/notes/implemented/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md +++ b/.agents/notes/archived/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md @@ -1,6 +1,7 @@ # Agent Note: 由评审驱动的 Issue 生命周期触发器 Status: implemented +Archived: 2026-08-10 [English](2026-08-08-review-driven-issue-lifecycle-triggers.md) | 中文 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml index 21c6e32e11..9e32647a60 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.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-06-21-mandatory-app-attribution-headers.md -2026-06-21-mandatory-app-attribution-headers.md: 12482ca80d19e5cd1e62b8860865db6cccea61ab -2026-06-21-mandatory-app-attribution-headers.zh.md: 43f356887948b88d34ba41d46ed1cd2f1c89a1e2 +2026-06-21-mandatory-app-attribution-headers.md: 39050a53ec76e8c5a6cac4d8e31fa15b992c406e +2026-06-21-mandatory-app-attribution-headers.zh.md: bfd6aa2540022f68cf9f69ccc2c12b3bc0978960 diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md index 12482ca80d..39050a53ec 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.md @@ -32,7 +32,7 @@ The provider-neutral identity is owned by `dsh-llm` (`packages/llm/llm/src/attri - product token for `User-Agent`: `deepseek-harness` (continuity with the pre-Agent Note wire value and the repo/org identity) - version: read from the owning package's manifest via `createRequire`, never a hand-copied constant -- app URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` - the planned public home, which must exist before release +- app URL: `https://github.com/deepseek-ai/deepseek-harness` - the repository home The default is mandatory and non-empty. White-label deployments pass their own `AppIdentity` to `attributionHeaders(identity)` - the override hook is the function parameter, with no deployment config plumbing until a consumer needs it - and omission falls back to the harness default rather than suppressing attribution. There is no per-request API for the model, user prompt, session id, cwd, user email, API key owner, or local machine identity to influence these fields. @@ -77,8 +77,6 @@ The landed contract: **Providers see that traffic comes from the harness.** That is the point, but it means deployments that previously blended into generic SDK traffic become identifiable. Mitigation: send only static public product data and let forks/white-label deployments pass their own `AppIdentity`. -**The app URL points at a repository that does not exist yet.** `deepseek-ai/deepseek-harness-sdk` is the planned public home; until it is created the URL is a dangling promise that blocks release. - **Header support differs by client library.** The hand-rolled adapter sets headers directly; the pi-ai-backed adapter depends on pi-ai continuing to honor `StreamOptions.headers` (merged last over provider defaults). The wire-level mock-server tests are the guard: if a pi-ai upgrade stops delivering the header, the suite goes red. This is useful pressure on the abstraction: a provider adapter that cannot set mandatory headers cannot fully implement the harness LLM contract. **OpenRouter rankings do not benefit yet.** `User-Agent` is the correct baseline for provider-neutral HTTP identity, but it will not create OpenRouter app pages or rankings because OpenRouter requires `HTTP-Referer` for that product feature. That is deliberate: public app marketplace participation is a separate product decision, not a prerequisite for mandatory request attribution. diff --git a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md index 43f3568879..bfd6aa2540 100644 --- a/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md +++ b/.agents/notes/implemented/architecture/2026-06-21-mandatory-app-attribution-headers.zh.md @@ -32,7 +32,7 @@ OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title` - `User-Agent` 的产品 token:`deepseek-harness`(与 Agent Note 之前的线路值及仓库/组织身份保持连续性) - 版本:通过 `createRequire` 从所属包的 manifest(元数据清单)读取,绝不手动复制常量 -- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness-sdk`——计划中的公开主页,且必须在发布前实际存在 +- 应用 URL:`https://github.com/deepseek-ai/deepseek-harness`——仓库主页 默认值是强制的且非空。白标部署通过向 `attributionHeaders(identity)` 传入自己的 `AppIdentity` 来覆盖——覆盖钩子就是函数参数,在有消费方需要之前不做部署配置管道——省略时回退到 harness 默认值而非抑制归属。没有逐请求 API 允许模型、用户提示词、会话 id、cwd、用户邮箱、API key 所有者或本地机器身份影响这些字段。 @@ -77,8 +77,6 @@ OpenRouter 应用归属刻意未实现。`HTTP-Referer`、`X-OpenRouter-Title` **提供方看到流量来自 harness。** 这正是目的,但意味着此前混在通用 SDK 流量中的部署变得可识别。缓解措施:仅发送静态公开产品数据,并允许 fork/白标部署传入自己的 `AppIdentity`。 -**应用 URL 指向一个尚不存在的仓库。** `deepseek-ai/deepseek-harness-sdk` 是计划中的公开主页;在它创建之前,该 URL 是一个阻塞发布的悬空承诺。 - **不同客户端库的头部支持有差异。** 手写适配器直接设置头部;基于 pi-ai 的适配器依赖 pi-ai 继续尊重 `StreamOptions.headers`(最后合并覆盖提供方默认值)。线路级 mock 服务器测试是守卫:如果 pi-ai 升级后不再投递该头部,套件会变红。这对抽象施加了有益的压力:一个无法设置强制头部的提供方适配器不能完整实现 harness 的 LLM 约定。 **OpenRouter 排名尚未受益。** `User-Agent` 是提供方无关的 HTTP 身份的正确基线,但它不会创建 OpenRouter 应用页面或排名,因为 OpenRouter 要求 `HTTP-Referer` 来实现该产品功能。这是有意为之:公开应用市场参与是一个独立的产品决策,不是强制请求归属的前提。 diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml index 8bef07d042..a48de33ce2 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.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-10-single-file-executable-sdk-runtime-distribution.md -2026-07-10-single-file-executable-sdk-runtime-distribution.md: a45678c9bb5fcae340ff7134687890879f56c630 -2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: f1fccc508471356dd6434da0e126ed38f15ed3ba +2026-07-10-single-file-executable-sdk-runtime-distribution.md: c2b6d9ff1825915e39738bf8f782c302ecfc1d0d +2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md: d7758a77083e07b1d2cac99ae2be3f15e6edd2dc diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md index a45678c9bb..c2b6d9ff18 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md @@ -34,27 +34,27 @@ Config discovery has two channels and fails loudly when both are missing: the `D ### Plugin resolution: the VFS holds a real package tree, the closure manifest IS the deploy root -Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`); the Loader resolves plugin names through standard dynamic `import()`: bare specifiers resolve upward along `node_modules` from the Loader's position inside the VFS, and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. +Inside the exe's VFS sits a **real package tree in build-artifact form** (each package's `lib/` plus a real `node_modules`). The packaged JSON-RPC entry supplies its installed harness base to app-boot's root Include: relative plugin specifiers resolve from the external configuration directory, while bare package names resolve from the VFS, so a configuration inside another Node project cannot shadow the packaged plugin set. The ordinary development bin leaves bare packages configuration-owned. Bare specifiers in the packaged entry resolve upward along `node_modules` from the entry's position inside the VFS and land inside the VFS naturally. The closed set needs no allowlist code — the set is whatever the VFS has installed, and importing a name outside the set fails. The deploy root is [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json) (`dsh-jsonrpc-agent-pkg`, a pnpm workspace member and a zero-code pure dependency manifest) — the unified source of truth for "which plugins the exe ships" and "what the Python runtime distributes". Adding a plugin to the exe = adding one dependency line to the manifest and repackaging. [`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) traverses every workspace package covered by that manifest and requires every non-optional workspace peer at the runtime root, reporting the complete referencing-package → missing-peer chain; `pnpm run hygiene`, CI static, and the single-exe build run it before packaging. Deploy also packs by each package's `files`, so the shared chunks tsdown splits out must be covered by `files`. ### Build pipeline and artifacts -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted yields a zero-symlink file tree (most stable for the pkg VFS, physically guaranteeing a single cordis instance); disabling automatic peer installation keeps unpublished package names from triggering registry resolution; link-workspace-packages points the closure at workspace/vendor sources. +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts): runtime closure verification → `pnpm run build` → (after clearing) `pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **directly into** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → restore any direct workspace package that legacy deploy hoisted back under the source manifest's `node_modules`, omitting its package-local dependency tree and rejecting any remaining manifest gap → replace every staged dependency symlink with its target bytes, remove package-manager `.bin` links, and fail if any symlink remains → inject the pkg configuration (`bin` points at `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` inside the closure, `assets` is a full glob — dynamic import is invisible to pkg's static analysis, so everything must be packed in explicitly) → stage the target `node-pty` addon → one `pkg --sea` per target → the executables `dsh-jsonrpc-agent-pkg--` land in `dist-exe/` and are copied back into the runtime directory. Linux installs build `pty.node` from source, so the builder copies it from the root install into the staged closure because legacy deploy omits that side-effect directory; macOS uses its target prebuild and emits the required `-spawn-helper` beside the executable. CI treats these products as intermediate test inputs and retains their platform wheels. All four deploy flags are grounded in measurement: `--legacy` is the mandatory path with inject-workspace-packages off; hoisted gives pkg a stable single-instance layout that the explicit materialization pass makes symlink-free; disabling automatic peer installation prevents undeclared peers from expanding the closure; link-workspace-packages selects direct workspace dependencies. [`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) overrides the transitive `@deepseek-ai/cosmokit` and `@deepseek-ai/schemastery` semver requests to the pinned vendor sources so legacy deploy never resolves those unpublished names from a registry. CI: [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml), triggered explicitly only — `workflow_dispatch`, or the `build-exe` label on a pull request; native builds on the three platforms linux-x64 / linux-arm64 (`ubuntu-24.04-arm`) / macos-arm64, with `~/.pkg-cache` cached; macOS ad-hoc signing is handled by pkg. Each leg drives a mock SSE model through the SDK with the default config and a custom `cordis.yml`, drives the exe directly over NDJSON JSON-RPC, verifies the JSONL and final response, and installs release-shaped wheels into a clean venv without `runtime_bin`; Linux additionally inspects GLIBC requirements and runs in a manylinux 2.28 container. A full three-target run retains four artifacts, each containing one release file: the platform-independent SDK wheel and three native runtime wheels; a subset dispatch retains the SDK wheel and selected runtime wheels. Bare executables and source bundles remain intermediate test inputs. [`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) accepts only `python-vX.Y.Z` tag pipelines whose version matches the root `package.json`, builds one SDK wheel and three native runtime wheels, then a single serialized job checks and publishes all four to the project PyPI registry. Windows is a non-goal. ### Python SDK distribution: two carriers, exe for production, node for development -The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. +The Python SDK lives at [`python/`](../../../../python/README.md): `python/sdk` (the client) + `python/sdk-runtime` (the runtime carrier package). The runtime package's data directory holds the checked-in default `runtime/cordis.yml`, the build-injected platform exe and optional helper, and the build-injected `runtime/node/` closure tree. `resolve_bundled_launch_args()` automatic resolution **finds the exe only**; the node carrier is enabled only by an explicit `DSH_RUNTIME_MODE=node` (running `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`, requiring a system node ≥22.19), positioned as the development-verification channel for members of this repo, and does not enter wheel distributions. -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with the SDK depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) reads the authoritative stable `X.Y.Z` from the repository root `package.json` and stages both packages at that version, with `deepseek-harness-sdk` depending exactly on `deepseek-harness-runtime-bin==X.Y.Z`. An optional `python-vX.Y.Z` release tag is a consistency assertion and is rejected when it differs from the repository version; the source `pyproject.toml` development sentinel never determines a release version. The SDK is a `py3-none-any` wheel; each wheel-only runtime package contains one exe, and the macOS wheel also contains its architecture-matched helper. Runtime wheels use one of `py3-none-manylinux_2_28_x86_64`, `py3-none-manylinux_2_28_aarch64`, or `py3-none-macosx_11_0_arm64`; the Hatch hook rejects sdists, universal tags, mixed-platform payloads, missing or extra helpers, and unsupported platforms. The exe's "must be explicitly configured" hard semantic is unchanged; the zero-config experience is restored by the wrapper: when the caller gave no `cordis`, named no explicit runtime, and the environment has no `DSH_CORDIS_CONFIG`, the client explicitly injects the checked-in default `cordis.yml` (agent-core + preloaded llm-deepseek + JSONL persistence + bash-local + the `dsh-jsonrpc` serving entry, with `!!js` environment-variable fallbacks) via `DSH_CORDIS_CONFIG`. ### Naming lineage -`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python dist names are `deepseek-harness` / `deepseek-harness-runtime-bin`. +`@deepseek-ai/dsh-jsonrpc-demo` (the package) → `dsh-jsonrpc-agent` (the bin) → `dsh-jsonrpc-agent-pkg` (the closure manifest; no scope prefix, deliberately sidestepping the constraints' package-shape rules for `@deepseek-ai/dsh-*`) → `dsh-jsonrpc-agent-pkg--` (the exe artifacts). The wire `serverInfo.name` stays `deepseek-harness-sdk-runtime` (a protocol-stable value); the Python distribution names are `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`, while the import modules remain `deepseek_harness` / `deepseek_harness_runtime`. ## Disposition of worker-style plugins @@ -62,7 +62,7 @@ The exe's "must be explicitly configured" hard semantic is unchanged; the zero-c ## Testing -The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, and the direct binary protocol, with final text and JSONL checked. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. +The verification surface has three tiers. Mechanism tier: the measured conclusions for the `--sea` chain are embedded in the Decision sections (ESM dynamic import inside the VFS, single cordis instance, fail-loud config chain, `node:sqlite`, macOS ad-hoc signing runs). SDK tier: the complete keyless pytest suite covers the client protocol against a fake runtime peer, subprocess cleanup, absolute cwd propagation, dual-carrier launch, and carrier resolution; root CI runs it on Python 3.10. End-to-end tier: every platform build completes a turn against a mock endpoint through the default SDK path, a custom config, the checked-in standalone minimal composition, and the direct binary protocol, with final text and JSONL checked. The minimal run asserts its exact system prompt and two-tool catalog, retains Bash state across calls, and invokes the editor. The custom config additionally drives `run_code` and a zero-agent `workflow` through their real worker files inside the packaged VFS. The same build leg runs a committed executable-specific snapshot through the Python SDK: a keyless scripted model mounts a Cordis plugin that registers a tool, invokes that tool from `run_code`, runs a direct spawn subagent and a workflow that starts a second spawn child, then unmounts the plugin. The fixture explicitly disables its unused bundled Bash and local skill discovery so its tool set does not depend on repository-external state, and the comparison normalizes opaque message IDs in the SDK result and notification stream plus the parent and two child JSONL logs. This harness stays separate from ACP's `pnpm run test:snapshot` because the protocols and build artifacts differ. The platform wheel is then installed in a clean venv and run without `runtime_bin`. Manual-driving caveat: the bin treats stdin EOF as "the client is gone" and disposes immediately, so a short-lived pipe aborts an in-flight turn — pipe-driven runs must keep stdin open until the turn ends. diff --git a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md index f1fccc5084..d7758a7708 100644 --- a/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.zh.md @@ -34,27 +34,27 @@ exe 使用 [@yao-pkg/pkg](https://github.com/yao-pkg/pkg)(vercel/pkg 归档后 ### 插件解析:VFS 装载真实包树,闭包 manifest(元数据清单)就是部署根目录 -exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。loader 通过标准动态 `import()` 解析插件名:裸包名从 VFS 内 loader 所在位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 +exe 的 VFS 内是**构建产物形态的真实包树**(各包的 `lib/` + 真实 `node_modules`)。打包专用 JSON-RPC 入口会向 app-boot 的根 Include 提供自身已安装 harness 的基准位置:相对插件说明符从外部配置目录解析,裸包名则从 VFS 解析,因此位于另一个 Node 项目内的配置无法遮蔽已打包的插件集合。普通开发 bin 仍由配置项目提供裸包。打包入口中的裸包名从该入口在 VFS 内的位置沿 `node_modules` 向上解析,自然落在 VFS 内。封闭集不需要白名单代码——VFS 中安装了什么,集合中就有什么;`import()` 集合外的名称会失败。 部署根目录是 [`python/sdk-runtime/package.json`](../../../../python/sdk-runtime/package.json)(`dsh-jsonrpc-agent-pkg`,pnpm 工作区成员、零代码纯依赖 manifest),也是「exe 安装哪些插件」与「Python 运行时分发什么」的统一真源。向 exe 添加插件,就是在 manifest 中增加一行依赖后重新打包。[`scripts/verify-runtime-closure.ts`](../../../../scripts/verify-runtime-closure.ts) 遍历该 manifest 覆盖的全部工作区包,要求每个非可选的工作区对等依赖(peer dependency)都显式列在运行时根目录,并报告“引用包 → 缺失对等依赖”的完整链路;`pnpm run hygiene`、CI 静态检查与 single-exe 构建都会在打包前运行该门禁。部署还会依据各包的 `files` 字段打包,因此 tsdown 拆出的共享分片必须被 `files` 覆盖。 ### 构建管线与产物 -[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 产出无符号链接的文件树(对 pkg VFS 最稳定,并从物理上保证只有一个 Cordis 实例);关闭对等依赖自动安装可避免未发布包名触发注册表解析;`link-workspace-packages` 让闭包指向工作区/vendor 源码。 +[`scripts/build-exe-for-python-sdk.ts`](../../../../scripts/build-exe-for-python-sdk.ts):运行时闭包校验 → `pnpm run build` →(清空后)`pnpm --filter dsh-jsonrpc-agent-pkg deploy --legacy --prod --config.node-linker=hoisted --config.auto-install-peers=false --config.link-workspace-packages=true` **直接写入** `python/sdk-runtime/src/deepseek_harness_runtime/runtime/node/` → 恢复被 legacy deploy 提升回源 manifest 的 `node_modules` 下的任何直接工作区包,同时省略其包内依赖树,并拒绝剩余的 manifest 缺口 → 将暂存依赖中的每个符号链接替换为目标文件内容,删除包管理器的 `.bin` 链接,并在仍有任何符号链接时失败 → 注入 pkg 配置(`bin` 指向闭包内的 `node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`;`assets` 使用全量 glob,因为动态 `import()` 对 pkg 静态分析不可见,必须显式打入全部内容)→ 暂存目标平台的 `node-pty` addon → 每个构建目标调用一次 `pkg --sea` → 可执行文件 `dsh-jsonrpc-agent-pkg--` 写入 `dist-exe/`,并拷回运行时目录。Linux 安装会从源码构建 `pty.node`,而 `--legacy` 部署会省略该副作用目录,因此构建器会把它从根安装目录复制到暂存闭包;macOS 使用对应目标的预构建产物,并在可执行文件旁生成所需的 `-spawn-helper`。CI 将这些产物作为测试中间输入,只保留对应平台的 wheel 包。四个部署标志都有实测依据:未启用 `inject-workspace-packages` 时必须使用 `--legacy`;`hoisted` 为 pkg 提供稳定的单实例布局,再由显式物化步骤消除符号链接;关闭对等依赖自动安装可防止未声明的对等依赖扩大闭包;`link-workspace-packages` 选择直接工作区依赖。[`pnpm-workspace.yaml`](../../../../pnpm-workspace.yaml) 将传递的 `@deepseek-ai/cosmokit` 与 `@deepseek-ai/schemastery` semver 请求覆盖到固定的 vendor 源码,使 legacy deploy 不会从注册表解析这些未发布名称。 CI 使用 [`.github/workflows/build-exe-for-python-sdk.yml`](../../../../.github/workflows/build-exe-for-python-sdk.yml),且只允许显式触发:手动派发 `workflow_dispatch`,或给 PR(Pull Request)添加 `build-exe` 标签。linux-x64、linux-arm64(`ubuntu-24.04-arm`)和 macos-arm64 三个平台分别进行原生构建,并缓存 `~/.pkg-cache`;macOS 的 ad-hoc 签名由 pkg 处理。每个平台都使用 mock SSE(Server-Sent Events)模型,分别通过默认配置和自定义 `cordis.yml` 驱动 SDK,再通过 NDJSON JSON-RPC 直接驱动 exe,校验 JSONL 与最终响应;最后把发布形态的 wheel 包安装到干净的 venv 中,并在不传 `runtime_bin` 的情况下运行。Linux 还会检查 GLIBC 依赖,并在 manylinux 2.28 容器中运行。完整构建三个目标时保留 4 个产物,每个产物只含一个发布文件:平台无关的 SDK wheel 包与 3 个原生运行时 wheel 包;手动选择部分目标时保留 SDK wheel 与所选运行时 wheel。裸 exe 与源码包只作为测试中间输入。[`.gitlab-ci.yml`](../../../../.gitlab-ci.yml) 只接受版本与根目录 `package.json` 匹配的 `python-vX.Y.Z` 标签流水线,构建一个 SDK wheel 包和 3 个原生运行时 wheel 包,再由单个串行任务校验并将这 4 个文件发布到项目的 PyPI 注册表。Windows 不在目标范围内。 ### Python SDK 分发:双载体,exe 用于生产,`node` 用于开发 -Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 +Python SDK 位于 [`python/`](../../../../python/README.md):`python/sdk` 是客户端,`python/sdk-runtime` 是运行时载体包。运行时包的数据目录包含检入的默认 `runtime/cordis.yml`、构建注入的平台 exe 与可选 helper,以及构建注入的 `runtime/node/` 闭包树。`resolve_bundled_launch_args()` 的自动解析**只查找 exe**;`node` 载体仅在显式设置 `DSH_RUNTIME_MODE=node` 时启用(运行 `runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`,需要系统 Node ≥22.19),定位为本仓库成员的开发验证通道,不随 wheel 包分发。 -[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 SDK 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 +[`scripts/build-python-release.py`](../../../../scripts/build-python-release.py) 从仓库根目录的 `package.json` 读取权威的稳定版本 `X.Y.Z`,以该版本暂存两个包,并让 `deepseek-harness-sdk` 精确依赖 `deepseek-harness-runtime-bin==X.Y.Z`。可选的 `python-vX.Y.Z` 发布标签只是一项一致性断言,与仓库版本不同时会被拒绝;源码 `pyproject.toml` 中的开发占位版本从不决定发布版本。SDK 是 `py3-none-any` wheel 包;每个只提供 wheel 包的运行时包都包含一个 exe,macOS wheel 包还包含与其架构匹配的 helper。运行时 wheel 包使用 `py3-none-manylinux_2_28_x86_64`、`py3-none-manylinux_2_28_aarch64` 或 `py3-none-macosx_11_0_arm64` 三种标签之一;Hatch 钩子拒绝 sdist、通用标签、混合平台载荷、helper 缺失或多余,以及不支持的平台。 exe「必须显式配置」的硬语义不变;零配置体验由包装层恢复:调用方没有提供 `cordis`、没有显式指定运行时,且环境中没有 `DSH_CORDIS_CONFIG` 时,客户端将检入的默认 `cordis.yml`(`agent-core` + 预载的 `llm-deepseek` + JSONL 持久化 + `bash-local` + `dsh-jsonrpc` 对外服务条目,并通过 `!!js` 使用环境变量兜底)显式注入 `DSH_CORDIS_CONFIG`。 ### 命名血统 -`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发名为 `deepseek-harness` / `deepseek-harness-runtime-bin`。 +`@deepseek-ai/dsh-jsonrpc-demo`(包)→ `dsh-jsonrpc-agent`(`bin`)→ `dsh-jsonrpc-agent-pkg`(闭包 manifest;没有作用域前缀,刻意避开 `constraints` 对 `@deepseek-ai/dsh-*` 的包形状规则)→ `dsh-jsonrpc-agent-pkg--`(exe 产物)。协议字段 `serverInfo.name` 保持为 `deepseek-harness-sdk-runtime`(协议稳定值);Python 分发包名为 `deepseek-harness-sdk` / `deepseek-harness-runtime-bin`,导入模块名仍为 `deepseek_harness` / `deepseek_harness_runtime`。 ## 工作线程插件 @@ -62,7 +62,7 @@ exe 内支持 `dsh-workflow-workerthread` 与 `dsh-code-runtime-worker`。两个 ## 测试 -验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 +验证面分三层。机制层:`--sea` 链路的实测结论内嵌在「决策」各节(VFS 内 ESM 动态 `import()`、单一 Cordis 实例、明确报错的配置链路、`node:sqlite`、macOS ad-hoc 签名可运行)。SDK 层:完整的无密钥 pytest 套件以 mock 运行时对端覆盖客户端协议、子进程清理、绝对 `cwd` 传递、双载体启动与载体解析;根 CI 在 Python 3.10 上运行全部用例。端到端层:每个平台构建都通过默认 SDK 路径、自定义配置、仓库内置的独立 minimal 组合和直接二进制协议,对 mock 端点完成一个轮次,并校验最终文本与 JSONL。minimal 运行会断言其精确系统提示词与双工具目录,跨调用保留 Bash 状态,并调用编辑器。自定义配置还会通过打包进 VFS 的真实工作线程文件执行 `run_code` 和不启动 agent 的 `workflow`。同一构建任务还会经 Python SDK 运行一组检入的 exe 专用快照:无密钥脚本化模型挂载一个会注册工具的 Cordis 插件,从 `run_code` 调用该工具,运行一个直接 spawn 的 subagent 和一个会通过 spawn 启动第二个 subagent 的工作流,随后卸载该插件。该 fixture(测试前置数据)会显式禁用组合包中未使用的 Bash 和本地 skill(技能)发现,使其工具集不依赖仓库外部状态;比较时会规范化以下各处的不透明消息 ID:SDK 结果与通知流,以及父会话和两个子会话的 JSONL 日志。该 harness 与 ACP 的 `pnpm run test:snapshot` 保持独立,因为二者的协议和构建产物不同。随后把平台 wheel 包安装进干净的 venv,并在不传 `runtime_bin` 的情况下运行。 手工驱动注意:`bin` 将 stdin EOF 视为「客户端已离开」并立即 dispose,短命管道会中止进行中的轮次——管道驱动必须保持 stdin 打开,直到轮次结束。 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml index bc2d26325d..3d370c1e37 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.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-19-gui-layering-and-rpc-protocol.md -2026-07-19-gui-layering-and-rpc-protocol.md: 514deb890d4e08d465db869669078473d32fb215 -2026-07-19-gui-layering-and-rpc-protocol.zh.md: f6fa71e3dac25f48b2ad4744a0cc695417528b34 +2026-07-19-gui-layering-and-rpc-protocol.md: da96ae97f2a2d64aeef7794bd82ccbd86602b1ad +2026-07-19-gui-layering-and-rpc-protocol.zh.md: 36dc7391bc3f9bb0d5105fea14a2763d0b7159a1 diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md index 514deb890d..da96ae97f2 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.md @@ -10,7 +10,7 @@ English | [中文](2026-07-19-gui-layering-and-rpc-protocol.zh.md) We need a UI integration layer. Beyond the existing ACP/stdio baseline, more product clients are coming — Web (server), Electron, and others. We call them Clients and want the following capabilities: -- One `dsh` process supporting both `dsh web` (serve) and `dsh run` (headless) — one process, two modes (a design reservation) +- One `dsh` process supporting both `dsh web` (serve) and `dsh --profile headless` (headless) — one process, two modes (a design reservation) - Launching inside Electron with the same Web technologies as `dsh web` That demands a stable layered responsibility model in the engineering codebase, so future clients plug in cleanly. @@ -31,7 +31,7 @@ Directories layer as follows: - **Fetch-arrival plugin packages** (`ui-layout`, `ui-sidebar`, `ui-conversation`, `ui-trajectory`): dual-entry — the root index is the node half (an empty `apply`, existing so the host Loader governs lifecycle and the web plugin registry discovers the package.json `dsh.client` declaration); the implementation lives under `src/client/`, shipped as the `./client` subpath (a tsdown closure-factory bundle). Cross-plugin consumption of `/client` is type-only; value cooperation goes through cordis services. - `apps/` holds the externally exported applications, assembled from Client / Host mixtures. - `apps/web` (`dsh-frontend`) is the vite application: a thin `main.ts` over the shell surface exported by `dsh-client-web`. - - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh run` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. + - `apps/cli` (`@deepseek-ai/dsh`) dispatches commands: `dsh web` = Host + webserver + the built `dsh-frontend` dist; `dsh --profile headless` = [a direct core Agent/Session entry point](2026-08-09-headless-direct-core-entry-point.md), with zero Host, HTTP, or browser layer. - A future Electron application reuses the same web client packages over an IPC fetch carrier. ``` @@ -79,7 +79,7 @@ Packages under `packages/host/*` and `packages/client/*` **must carry the direct 2. **Write an assembly module under `apps/`**: `startHost()` + a client subclass + the application's private signal/print/exit semantics; a mixture never becomes a package — assembly is written in the app. 3. **Import `dsh-host-webserver` only if you need HTTP carriage**, otherwise zero ports. -The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh run` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch. +The two existing applications preserve the division: the Web application mounts Host, carrier, and browser composition, while `dsh --profile headless` mounts a direct core runner with zero Host, HTTP, or ports. ACP-class protocol bridges do not follow the client-carrier checklist: they expose core to the external ecosystem and mount directly via `ctx.plugin(entry-point plugin)` without fetch. ## Message protocol @@ -215,7 +215,7 @@ All four quadrant full forms pass through `onEnvelope`; the base implementation | Subclass | Package | doFetch | Purpose | |---|---|---|---| -| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh run` drives core directly | +| `InProcessApiClient` | apiproxy itself | the injected `{ fetch }` handler | **The isomorphic point**: `new InProcessApiClient(toFetchHandler(api))` never touches the network yet runs the real wire serialization/zod/SSE framing; carrier tests and callers can exercise the protocol without opening a port, while product `dsh --profile headless` drives core directly | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` uplink + one same-origin WebSocket downlink per logical stream | the browser client; physical boundary in the [WebSocket downlink carrier](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | unused (protocol-layer override) | serverless UI development (`?fixture`): overrides the `callUnary`/`openMux`/`openHost`/`respond` virtuals and is itself the fake server (frame rpcIds minted by it, semantics self-consistent) | | IPC bridge subclass (hypothetical example — no such shell exists) | an Electron shell | IPC serialization round trip | would swap only doFetch; contract and base class unchanged | diff --git a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md index f6fa71e3da..36dc7391bc 100644 --- a/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-19-gui-layering-and-rpc-protocol.zh.md @@ -9,7 +9,7 @@ Status: implemented ## Problem 需要提供 UI 对接层,除已有 ACP(Agent Client Protocol)/stdio 基线外,还需要 Web(server)、Electron 等其他产品客户端。我们把它们统一称为 Client。希望具备以下能力: -- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh run`(headless),一个进程两种模式(设计预留) +- 一个 `dsh` 进程同时支持 `dsh web`(启动)和 `dsh --profile headless`(headless),一个进程两种模式(设计预留) - 在 Electron 中使用与 `dsh web` 相同的 Web 技术启动 那么当前的工程代码需要稳定的分层职责模型,便于以后接入各类 client。 @@ -29,7 +29,7 @@ Status: implemented - **fetch 到达插件包**(`ui-layout`、`ui-sidebar`、`ui-conversation`、`ui-trajectory`):双入口——根入口是 node 半边(空 `apply`,其存在是为了让 host Loader 管辖生命周期、让 web 插件注册表发现 package.json 的 `dsh.client` 声明);实现住在 `src/client/` 下,经 `./client` 子路径发布(tsdown 闭包工厂 bundle)。跨插件消费 `/client` 只限类型;值层面的协作走 cordis 服务。 - `apps/` 作为对外导出的应用入口,可以由 Client / Host 混合组装。 - `apps/web`(`dsh-frontend`)是 vite 应用:`dsh-client-web` 导出的壳表面之上的一层薄 `main.ts`。 - - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh run` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 + - `apps/cli`(`@deepseek-ai/dsh`)分发命令:`dsh web` = Host + webserver + 构建出的 `dsh-frontend` dist;`dsh --profile headless` = [直接使用核心 Agent/Session 的入口](2026-08-09-headless-direct-core-entry-point.md),不含 Host、HTTP 或浏览器层。 - 将来的 Electron 应用经由 IPC fetch 载体复用同一套 web client 包。 ``` @@ -77,7 +77,7 @@ TypeScript 以 solution 根引用的**两个聚合 program** 检查(`tsconfig. 2. **在 `apps/` 下写拼装模块**:`startHost()` + 客户端子类 + 该应用私有的信号/打印/退出语义;混合体不建包,拼装写在 app 里。 3. **需要 HTTP 承载才 import `dsh-host-webserver`**,否则零端口。 -现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh run` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。 +现有两个应用保持这一区分:Web 应用挂载 Host、载体与浏览器组合,而 `dsh --profile headless` 挂载直接使用核心服务的 runner,不包含 Host、HTTP 或端口。ACP 类协议桥不遵循 client 载体清单:它把 core 暴露给外部生态,直接通过 `ctx.plugin(入口插件)` 挂载,不使用 fetch。 ## 消息协议 @@ -213,7 +213,7 @@ export type ResponseValue = | 子类 | 所在包 | doFetch | 用途 | |---|---|---|---| -| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh run` 直接驱动 core | +| `InProcessApiClient` | apiproxy 本包 | 注入的 `{ fetch }` handler | **同构点**:`new InProcessApiClient(toFetchHandler(api))` 全程不过网络但真跑 wire 序列化/zod/SSE 帧;载体测试与调用方可以在不打开端口的情况下运行这套协议,而产品 `dsh --profile headless` 直接驱动 core | | `WebApiClient` | dsh-client-connection | `globalThis.fetch` 上行 + 每逻辑流一条同源 WebSocket 下行 | 浏览器客户端;物理边界见 [WebSocket 下行载体](2026-08-04-websocket-downlink-carrier.md) | | `FixtureApiClient` | dsh-client-connection | 不用(协议层覆写) | 无 server 的 UI 开发(`?fixture`):覆写 `callUnary`/`openMux`/`openHost`/`respond` 虚方法,自己就是假 server(帧 rpcId 由它 mint,语义自洽) | | IPC 桥子类(假想示例——尚无此形态) | Electron 壳 | IPC 序列化往返 | 只需换 doFetch,约定/基类零改 | diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml index 327eeef2fb..98319ccd48 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.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-25-web-client-session-scope-and-provide-channel.md -2026-07-25-web-client-session-scope-and-provide-channel.md: ff411e1b387dcd83b04f8c02d5dde3c70a815932 -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 593e194654bb3552ad3cac4534847fba11cf3694 +2026-07-25-web-client-session-scope-and-provide-channel.md: f371b93ccf6cb3ba10cbdb73baa670cbf889f393 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 78440131c4e09c9a458009fbe5a2a34a707a481c diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md index ff411e1b38..f371b93ccf 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.md @@ -93,7 +93,7 @@ Slot scope is the closed set `root | session-maybe | session`: - `session-maybe` follows the current session with ADOPTION identity (the only behavior — there is no hold-identity-forever mode): an incarnation born session-less keeps its React instance across the arrival of the FIRST session (the blank shell adopts it — no remount, the DOM survives), and from then on behaves exactly like a strict session entry — switching to a different session remounts, and dropping back to no-session remounts into a fresh blank incarnation that will adopt again. Component-local per-session state therefore clears by construction; state that must survive a switch belongs in session-bound sources (machine, store, hooks). With no session, `sessionId`, the results of `useSession`/`useInput`, and `inputActions` may all be absent. The unkeyed root `SessionMaybeProvider` drives these updates by subscribing to the runtime's atomic `currentProvide` projection — selection moves and provider-roster changes publish through the same source, so a roster change under a stable current id republishes the mounted bundle instead of stranding entries on an obsolete hook/prop schema — while `SessionMaybeProvideInfo` uses the static key map to retain the complete hook/prop shape even with no session; the per-entry adoption bookkeeping (incarnation-counter key) lives in the renderer's `SessionMaybeEntry`. - `session` guarantees that `sessionId`, every hook source, and every prop exist; each strict entry's error boundary is keyed by `sessionId`, so switching sessions recreates that entry and its session store. -`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session it renders inert (machine faces absent, `disabled` owner prop), and the same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. +`conversation` is the resident `session-maybe` shell: `ConversationRoot`, HeroShell, the Workspace picker, the root-owned scrollport and composer stack, and the overlay chain's fallback frame retain their React instances across the no-session → blank-session switch. Two strict entries fill fixed regions without reparenting that tree: `conversation.session.header` carries breadcrumb/tabs/actions above the scrollport, while `conversation.session` carries the view ring and draft mirror inside it; both share the same session-scoped chat store. The composer bar (`conversation.composer.bar`) is itself `session-maybe`: with no session its machine faces and message actions are inert, while the whole dashed card opens the existing Workspace picker by pointer and its read-only textarea does the same through Enter or Space. The same instance — textarea included — goes live when a session appears; the remaining input slots stay strict `session` and dispatch nothing until then. The blank → engaging/active transition never rebuilds the InputBar on a phase flip. - The runtime's first built-in entry: the `'session'` hook — `useSession` itself rides the same mechanism, no special-casing. - Concurrent discipline: the render plane reads only from the hooks compartment (uSES consistency guarantee); props-compartment callbacks are used only in event-handler space; descriptor resolution is render-safe (idempotent caching, with prune reaping residue from abandoned renders). @@ -132,5 +132,5 @@ Slot scope is the closed set `root | session-maybe | session`: - Plugins gain session context isomorphic to the host's: per-session state hangs on the actx and mounts/tears down in one piece with the scope fiber, making leaks structurally impossible; two-session isolation is structurally guaranteed by the scope filter. - The client object layer converges to a wire mirror: session identity, lifecycle, and capability adjudication all defer to the host entity — the input system (the next layer) always faces a session with a real Agent, and providers like slash/skill uniformly address by sessionId directly. - Blank-session governance takes zero dedicated mechanisms: state rides one derived bit, visibility rides the unified list projection (only the current blank shows, as `New Session`), reclamation rides lazy persistence's existing contract (evaporation on restart), and the ordinary ceiling rides same-Workspace reuse. -- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests; fully disabled input while no workspace is picked is an experience cost the product surface accepts (the price of the single state axis). +- The cost: the id→ctx handoff discipline and provide's Concurrent discipline are conventions rather than type-enforced, pinned by review and tests. The single state axis still withholds machine faces until a Session exists; the resident card routes activation to the Workspace picker during that interval ([decision](../feature/2026-08-07-workspace-picker-composer-entry.md)). - Known gaps: approval/question recovery across prune (TODO); model selection returns in live-mutation shape (the host `selectModel` trio is ready-made, its client consumer not yet built). diff --git a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md index 593e194654..78440131c4 100644 --- a/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-25-web-client-session-scope-and-provide-channel.zh.md @@ -93,7 +93,7 @@ slot scope 是闭集 `root | session-maybe | session`: - `session-maybe` 以**收养(adoption)身份语义**跟随 current session(唯一行为——不存在「永久保持实例」模式):空态出生的化身在**第一个** session 到来时保持 React 实例(空壳收养它——不重挂,DOM 存活);此后行为与严格 session entry 完全一致——切到不同 session 重挂,跌回无 session 也重挂为崭新的空态化身(之后再次收养)。因此组件本地的 per-session 状态**由构造保证**随切换清零;需要活过切换的状态必须住 session 绑定的源(machine、store、hooks)。无 session 时 `sessionId`、`useSession`/`useInput` 的选择结果及 `inputActions` 均可缺省。根部无 key 的 `SessionMaybeProvider` 通过订阅 runtime 的原子 `currentProvide` 投影驱动这条更新——选择移动和提供方名册变化经同一 source 发布,current id 不变时的名册变化也会重发已挂载 bundle,而不是把 entry 困在过期的钩子/prop 形状上——`SessionMaybeProvideInfo` 靠静态键表在无 session 时仍保留完整钩子/prop 形状;逐 entry 的收养记账(化身计数 key)住在 renderer 的 `SessionMaybeEntry`。 - `session` 保证 `sessionId`、所有钩子 source 与 props 均存在;每个严格 entry 的错误边界以 `sessionId` 为 key,切换 session 会重建该 entry 及其 session store。 -`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 session → blank session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时以惰性态渲染(machine face 缺席、`disabled` owner prop),session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 +`conversation` 是 `session-maybe` 的常驻外壳:`ConversationRoot`、HeroShell、Workspace picker、root 持有的 scrollport 与 composer stack,以及 overlay chain 的 fallback 外框,在无 session → blank session 的切换中保持 React 实例。两个严格 session entry 只填入固定区域,不改变该树的父级:`conversation.session.header` 在 scrollport 上方承载 breadcrumb/tab/action,`conversation.session` 在其内部承载 view ring 与 draft mirror;二者共享同一个 session scope chat store。composer bar(`conversation.composer.bar`)本身即为 `session-maybe`:无 session 时,其 machine face 和消息操作保持惰性,整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。session 出现后同一实例(含 textarea)转为 live;其余输入 slot 保持严格 `session`,在此之前不分发任何条目。blank → engaging/active 的 InputBar 不因 phase 翻转而重建。 - 运行时内建第一条:`'session'` 钩子——`useSession` 本身走同一机制,无特判。 - Concurrent 纪律:渲染平面只从 hooks 格读(uSES 一致性保证);props 格回调只在事件 handler 空间用;描述符解析 render-safe(幂等缓存、废弃渲染残留由 prune 收尸)。 @@ -132,5 +132,5 @@ slot scope 是闭集 `root | session-maybe | session`: - 插件获得与 host 同构的会话上下文:逐会话状态挂 actx、随 scope fiber 一次拆装,泄漏结构性不可能;双会话隔离由 scope filter 结构性保证。 - client 对象层收敛为 wire 镜像:会话身份、生命周期、能力判别全部以 host 实体为准——输入体系(下一层)面对的永远是「有真 Agent 的会话」,slash/skill 等提供方一律以 sessionId 直接寻址。 - 空会话治理零专用机制:状态靠一个派生位,可见性靠统一列表投影(仅 current blank 以 `New Session` 展示),回收靠 lazy persistence 的既有约定(重启蒸发),常规上限靠同 Workspace 复用。 -- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住;「未选 workspace」期间输入全禁是产品面接受的体验代价(单一状态轴换来的)。 +- 代价:id→ctx 换乘纪律、provide 的 Concurrent 纪律都是约定而非类型强制,靠 review 与测试钉住。单一状态轴仍会在 Session 存在前隐藏 machine face;这段时间内,常驻卡片会把激活操作转到 Workspace picker([决策](../feature/2026-08-07-workspace-picker-composer-entry.md))。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,其 client 消费方尚未构建)。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md deleted file mode 100644 index 1ebae5dbb1..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: Experimental and internal package group - -Status: implemented - -English | [中文](2026-07-28-experimental-plugin-package-group.zh.md) - -## Problem - -The [package hierarchy](../../../../packages/README.md) groups plugins by product role, but it cannot distinguish release packages from prototypes or internal-only packages. The team needs an obvious shared place for useful work that is not part of the official release. - -## Decision - -The subtree rules in [`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) make `packages/experimental//` the required home for Cordis plugin packages whose whole public contract is experimental or internal-only. Package names remain `@deepseek-ai/dsh-`. - -The group is the team's in-repository place to share engineering and product-manager prototypes: members can discover, run, review, and extend one another's work against the real plugin graph without implying product support. - -Official releases exclude this directory. A package enters a release only after moving to its product-role group; release packages cannot take runtime dependencies on packages here. Examples may use them, while any other runtime dependent also belongs here. Tests may use them as development dependencies. - -Experimental packages carry no stability, compatibility, migration, or support promise: they may change APIs, configuration, or data, or disappear without deprecation or migration. Internal-only packages may define narrower internal contracts but make no public release promise. Neither status relaxes engineering, security, documentation, lifecycle, testing, or snapshot requirements. - -The pending `@deepseek-ai/dsh-tui-session-changes` `/diff` viewer and `/btw` plugin are examples governed by this rule. Promotion into an official release requires explicit review of the public contract, limitations, test evidence, and a named owner accepting stable-package obligations. - -## Alternatives considered - -**Keep experimental and internal-only packages in product-role groups with README labels.** Labels are easy to miss and cannot enforce dependency boundaries. - -**Treat every package as experimental until the first tagged release.** This provides no durable incubation boundary. - -**Develop prototypes and internal packages elsewhere.** This loses the real plugin graph, examples, snapshots, and lifecycle checks needed to evaluate them. - -## Consequences - -The path makes release exclusion and dependency blast radius visible while retaining the real plugin graph for team sharing. It gives up product-role colocation and creates path churn on promotion, while the npm name remains stable. The subtree rules, repository [current-owner/current-need rule](../../../../packages/AGENTS.md), and unchanged engineering gates limit junk-drawer growth. Because official release tooling does not yet exist, contributor policy enforces the exclusion; when such tooling is added, the directory is its required exclusion boundary. diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md b/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md deleted file mode 100644 index 2d09451c50..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.zh.md +++ /dev/null @@ -1,33 +0,0 @@ -# Agent Note: 实验性与内部专用包分组 - -Status: implemented - -[English](2026-07-28-experimental-plugin-package-group.md) | 中文 - -## 问题 - -[包层级结构](../../../../packages/README.md)按产品角色对插件分组,但无法区分发布包、原型和内部专用包。团队需要一个明确的共享位置,存放不属于官方发布版本的有价值成果。 - -## 决策 - -[`packages/experimental/AGENTS.md`](../../../../packages/experimental/AGENTS.md) 中的子树规则要求所有公开约定整体处于实验状态或仅限内部使用的 Cordis 插件包位于 `packages/experimental//`。包名仍为 `@deepseek-ai/dsh-`。 - -该分组供团队在仓库内共享工程人员和产品经理制作的原型:成员可以基于真实插件图发现、运行、评审并扩展彼此的原型,但这不代表产品会提供支持。 - -官方发布版本不包含此目录。包只有移入对应的产品角色分组后才会纳入发布版本;发布包不得在运行时依赖此处的包。示例可以使用这些包;其他任何运行时依赖方也必须位于此处。测试可以将它们用作开发依赖。 - -实验性包不提供稳定性、兼容性、迁移或支持保证:其 API、配置或数据可以变更,包也可以移除,均不提供弃用期或迁移路径。内部专用包可以定义范围更窄的内部约定,但不作公开发布承诺。无论哪种状态,都不降低仓库对工程、安全、文档、生命周期、测试或快照的要求。 - -尚待完成的 `@deepseek-ai/dsh-tui-session-changes` `/diff` 查看器和 `/btw` 插件都受这项规则约束。将包提升为稳定包并纳入官方发布版本,需要明确评审其公开约定、限制和测试证据,并指定一名愿意承担稳定包义务的负责人。 - -## 考虑过的替代方案 - -**将实验性和内部专用包留在产品角色分组中,并用 README 标注。** 标注容易被忽略,也无法强制执行依赖边界。 - -**首个带标签的版本发布前,将所有包都视为实验性。** 这无法提供持久的孵化边界。 - -**在其他位置开发原型和内部专用包。** 这会失去评估它们所需的真实插件图、示例、快照和生命周期检查。 - -## 后果 - -该路径明确标示不纳入发布版本的包及其依赖影响范围,同时保留供团队共享成果的真实插件图。代价是这些包无法与同产品角色的包共置,提升并纳入发布版本时还会产生路径变动,但 npm 包名保持稳定。子树规则、仓库已有的[「必须有当前负责人和实际需求」规则](../../../../packages/AGENTS.md)以及保持不变的工程门禁,可限制该分组无序膨胀。由于官方发布工具尚不存在,目前由贡献者政策执行这项排除规则;添加发布工具后,必须以该目录为排除边界。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml index 8f17ecd480..92744d0ead 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.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-29-dsh-source-launch-tsx-esm.md -2026-07-29-dsh-source-launch-tsx-esm.md: ed22e51d59a25db130b3760ce484c116bade4348 -2026-07-29-dsh-source-launch-tsx-esm.zh.md: bdd549092eb30f7749c8f7561068daafe3548b28 +2026-07-29-dsh-source-launch-tsx-esm.md: b2428602a780f2880f0f803ba59b16a76b39790e +2026-07-29-dsh-source-launch-tsx-esm.zh.md: 866bc8886725788e6e619f9507f5c8ccab63f82d diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md index ed22e51d59..b2428602a7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md @@ -14,11 +14,11 @@ Startup latency also mattered: the off-thread `module.register()` hooks worker s ## Decision -The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. `bin/dsh`, the root `dsh`/`demo:tui`/`demo:web` scripts, and the Code Mode TUI overlay use the same vector; `bin/dsh` references the hook and tsconfig by absolute checkout paths (bare `tsx/esm` does not resolve from an arbitrary cwd) and pins `TSX_TSCONFIG_PATH` to the root tsconfig. The CJS hook stays off because the CLI source graph is ESM-only; measured TUI time-to-banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain. +The `dsh` TUI, Web, and headless source launches run `node --import tsx/esm`: tsx's ESM-only hook owns both TypeScript transformation and tsconfig `paths` projection. The root `dsh` script completes the repository build, then uses that vector from the repository root. The CJS hook stays off because the CLI source graph is ESM-only; measured runtime launch to the TUI banner is ~0.7s versus ~1.1s under the full tsx default and ~0.75s under the removed native chain. `scripts/tspath-loader.ts` and `apps/cli/src/tsconfig-paths-loader.ts` are deleted. With them went the loader's runtime rule of mapping a workspace import only for declared runtime dependencies — tsx applies the `paths` map unconditionally. Declaration completeness now rests on the static gates alone: `verify-cordis-config` for configured bare plugins, and workspace constraints for manifests. (That runtime rule found real bugs: `dsh-plan-mode` and `dsh-tool-tasks` imported `@deepseek-ai/dsh-llm` while declaring it only in devDependencies; since fixed.) -The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`): a keyless piped-stdio launch of the exact production vector asserting the non-zero-exit TTY refusal. Any future Node change to module hooks or TypeScript handling turns this gate red instead of breaking developers' `pnpm dsh`. +The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (`apps/cli/tests/source-launch.compat.spec.ts`): a keyless piped-stdio launch of the exact production runtime vector asserting the non-zero-exit TTY refusal. Any future Node change to module hooks or TypeScript handling turns this gate red instead of breaking developers' `pnpm dsh`. ## Alternatives considered @@ -35,4 +35,4 @@ The node-compat CI matrix (Node 22.19 and 26) gains `dsh-source-launch-smoke` (` - One launch vector across the whole engines range, including future Node lines that change native TypeScript support; the smoke gate enforces it per matrix line. - TypeScript transformation is delegated to tsx/esbuild again, reversing the prior note's goal of proving Node-native transformation; that goal is unreachable while vendored sources use non-erasable syntax and Node ships no transform mode. - The runtime declared-dependency enforcement in source launches is gone; undeclared workspace imports now surface only through static gates or built-mode resolution failures. -- Startup improves ~0.4s over the full tsx default (`demo:headless` now aliases the same `dsh run` source launch; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path). +- Runtime launch improves ~0.4s over the full tsx default; ACP keeps `--import tsx` because its graph was not audited for CJS-hook dependence and its launch latency is not on the interactive path. diff --git a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md index bdd549092e..866bc88867 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.zh.md @@ -14,11 +14,11 @@ Status: implemented ## 决策 -`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。`bin/dsh`、根目录的 `dsh`/`demo:tui`/`demo:web` 脚本以及 Code Mode TUI overlay 使用同一向量;`bin/dsh` 以 checkout 的绝对路径引用钩子与 tsconfig(裸的 `tsx/esm` 无法从任意 cwd 解析),并将 `TSX_TSCONFIG_PATH` 固定到根 tsconfig。CJS 钩子保持关闭,因为 CLI 源码图是纯 ESM;实测 TUI 到 banner 约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。 +`dsh` 的 TUI、Web 与无头源码启动运行 `node --import tsx/esm`:由 tsx 的 ESM-only 钩子同时负责 TypeScript 转换与 tsconfig `paths` 投影。根目录的 `dsh` 脚本先完成仓库构建,然后从仓库根目录使用同一启动方式。CJS 钩子保持关闭,因为 CLI(命令行界面)源码图是纯 ESM;实测运行时启动至 TUI banner 耗时约 0.7s,对比完整 tsx 默认形态约 1.1s、已移除的原生链约 0.75s。 `scripts/tspath-loader.ts` 与 `apps/cli/src/tsconfig-paths-loader.ts` 已删除。随之消失的还有该 loader「仅为已声明运行时依赖映射 workspace import」的运行时规则——tsx 无条件应用 `paths` 映射。声明完整性现在仅由静态门禁保障:配置的裸插件走 `verify-cordis-config`,manifest(元数据清单)走 workspace constraints。(该运行时规则确实发现过真实缺陷:`dsh-plan-mode` 与 `dsh-tool-tasks` 导入 `@deepseek-ai/dsh-llm` 却只声明在 devDependencies;后已修复。) -node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产启动向量做 keyless 管道 stdio 启动,断言非零退出的 TTY 拒绝。未来 Node 对模块钩子或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。 +node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(`apps/cli/tests/source-launch.compat.spec.ts`):以精确的生产运行时启动向量做 keyless 管道 stdio 启动,断言非零退出的 TTY 拒绝。未来 Node 对模块钩子或 TypeScript 处理的任何改动都会让该门禁变红,而不是破坏开发者的 `pnpm dsh`。 ## 备选方案 @@ -35,4 +35,4 @@ node-compat CI 矩阵(Node 22.19 与 26)新增 `dsh-source-launch-smoke`(` - 整个 engines 范围(包括未来改变原生 TypeScript 支持的 Node 版本线)只有一个启动向量;冒烟门禁按矩阵行强制执行。 - TypeScript 转换重新委托给 tsx/esbuild,逆转了前一篇 Agent Note「证明 Node 原生转换可用」的目标;在 vendor 源码使用不可擦除语法且 Node 不再提供 transform 模式的情况下,该目标不可达。 - 源码启动中的运行时依赖声明强制不复存在;未声明的 workspace import 现在只能通过静态门禁或构建模式的解析失败暴露。 -- 启动相比完整 tsx 默认形态快约 0.4s(`demo:headless` 现为同一条 `dsh run` 源码启动命令的别名;ACP 保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上)。 +- 运行时启动相比完整 tsx 默认形态快约 0.4s;ACP(Agent Client Protocol)保留 `--import tsx`,因为它的依赖图尚未就 CJS 钩子依赖性做审计,且其启动延迟不在交互路径上。 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml index c50bf478ad..f5c1e4cad3 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.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-29-package-regrouping.md -2026-07-29-package-regrouping.md: 3c37bce05bacd6af800a76ac93fb691b896a6772 -2026-07-29-package-regrouping.zh.md: 68903ff1fad6a975c4445fe8971c8fe0dd40117f +2026-07-29-package-regrouping.md: 30fc45a122263350b4a2ad1998850f631c20f9b8 +2026-07-29-package-regrouping.zh.md: a3a9a11ec71b7f894dcea7c733eb39a80b71ac50 diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md index 3c37bce05b..30fc45a122 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.md @@ -58,7 +58,7 @@ The moves landed as pure `git mv` moves, so rename detection carries the history A group move did not touch: npm names, imports, `cordis.yml` configs, snapshot fixtures, the `pnpm-workspace.yaml`/`tsdown` globs (both `packages/*/*`), or the Python runtime manifest — all reference packages by npm name. -`client/` and `host/` were out of scope and are unchanged. The `experimental/` group proposal (PR #844) is orthogonal — a release-boundary container, not a clustering decision. +`client/` and `host/` were out of scope and are unchanged. ## Alternatives considered diff --git a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md index 68903ff1fa..a3a9a11ec7 100644 --- a/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md +++ b/.agents/notes/implemented/architecture/2026-07-29-package-regrouping.zh.md @@ -58,7 +58,7 @@ Status: implemented 组移动未触及:npm 包名、import、`cordis.yml` 配置、快照 fixture(测试前置数据)、`pnpm-workspace.yaml` 与 `tsdown` 的 glob(都是 `packages/*/*`),以及 Python 运行时 manifest(元数据清单)——它们全部按 npm 包名引用包。 -`client/` 与 `host/` 不在本次范围内,保持不变。`experimental/` 组提案(PR #844)与本案正交:它是发布边界容器,不是聚类决策。 +`client/` 与 `host/` 不在本次范围内,保持不变。 ## Alternatives considered 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 deleted file mode 100644 index 38e7356d4a..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md +++ /dev/null @@ -1,47 +0,0 @@ -# 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 contract 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/boot/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 deleted file mode 100644 index 6833eeb427..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.zh.md +++ /dev/null @@ -1,47 +0,0 @@ -# Agent Note: 包管理器原生仓库缓存 - -Status: implemented - -[English](2026-07-30-package-manager-native-repository-cache.md) | 中文 - -## 问题 - -独立运行的 Harness 应用不能依赖开发者自有的 SDK 工程来声明并安装仓库依赖。因此,加载配置中的 GitHub 仓库需要一道持久的获取、准备与缓存边界;但如果在 DSH 内实现 Git 传输、托管来源语法、包准备流程和内容存储,就会重复实现包管理器。若要求用户另行安装包管理器,则只需修改配置即可使用的功能还会依赖宿主环境的额外配置。 - -缓存还需要明确更新标识。若没有独立的刷新协议,可变分支名无法既永久缓存,又反映后续 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(热模块替换),无需过早新增服务约定。 - -## 后果 - -- 独立应用随附 pnpm 约 18.6 MB 的解压后运行时,不要求全局工具,也无需自行实现 Git 与包处理。 -- 仓库作者可以使用常规包准备流程;恶意的已配置仓库或依赖可以在经过上述清理的子进程环境中,以用户的文件系统权限执行代码。 -- 精确 specifier 使首次安装成功后的启动具有确定性;更改缓存代码必须修改配置或 ref。 -- 安装失败不会留下已发布缓存项,可以再次重试。已发布缓存损坏时会明确报错,而不会在同一标识下静默重装。 -- 缓存代次会持续占用磁盘,直到未来有明确的缓存管理政策将其移除。 - -## 测试 - -`packages/boot/app-boot/tests/repository-cache.spec.ts` 覆盖同进程 single-flight、跨实例缓存复用、精确 specifier 隔离、失败暂存清理与重试,以及边界校验。其真实本地 Git 用例会调用随附的 pnpm,运行 fixture(测试前置数据)仓库的 `prepare` 脚本,并在不访问网络的情况下,从已安装缓存项中读取准备后的文件。 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 deleted file mode 100644 index c66ee111eb..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md +++ /dev/null @@ -1,49 +0,0 @@ -# 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. These portable static contributions still need to reuse the existing skill and MCP lifecycle owners when the same trusted package also carries native Cordis code. - -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 the static contribution subformat inside a `.dsh-plugin` package: 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. 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 package may additionally declare the explicit code entry owned by the [trusted repository package decision](2026-08-08-trusted-repository-package-code.md), and at least one code or static contribution is required. - -The `.dsh-plugin` package declares the published `@deepseek-ai/dsh-repository-plugin` package as a development dependency and a non-empty `scripts.prepack` that invokes its `dsh-plugin-prepare` executable. During Git installation, pnpm installs that dependency from the selected package's own manifest; `prepack` runs after dependency installation and before pnpm packs a selected subdirectory, including a Plugin nested inside another package-manager workspace. The package may build its code first. The helper validates metadata and source types, strictly parses `.mcp.json`, copies static assets into `dsh-plugin-assets`, and writes `dsh-plugin.mjs`; the source loader revalidates the installed package's helper-bearing lifecycle metadata before importing that wrapper. A static-only package still receives an import-free wrapper containing its normalized manifest, service-derived `inject` list, and delegation to the `dsh-repository-plugin` Loader builtin. The dependency and workspace-isolation rationale is in the [Git source preparation repair](../bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md). - -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. Repository instances enable strict startup, so an initial connection, discovery, or tool-registration failure rejects the repository Loader generation; non-strict standalone clients retain the logged successful-plugin/no-tools behavior. - -Unknown MCP fields reject. This intentionally excludes OAuth, `auth` objects, `CLAUDE_PLUGIN_ROOT`, and a broader Claude compatibility contract. Commands, hooks, agents, rules, and other foreign manifest conventions are not inferred from static repository layout; DSH-native behavior uses the explicit trusted Cordis entry. Repository subdirectory selection and GitHub source configuration belong to the [standalone app integration](../feature/2026-07-30-config-only-repository-plugins.md), not this static adapter. - -## Alternatives considered - -**Discover an entry from `main`, `exports`, or repository layout.** Rejected because static assets do not imply that a package's ordinary entry is a Cordis Plugin. Trusted code loading is explicit through `dsh.entry` and remains outside this static adapter's ownership. - -**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. - -**Make every MCP connect failure a Loader update failure.** Rejected because optional standalone MCP clients deliberately contain startup failures and expose no tools. The MCP client instead owns an explicit strict-startup option, which repository adapters enable for their declared servers. - -## Consequences - -- Existing skill/MCP repositories can add a small `.dsh-plugin/package.json` without relocating their assets or adopting an SDK project. -- Prepared static output is deterministic glue, while an optional `dsh.entry` and the configured repository 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 portable static contribution kind requires an explicit format and DSH-owned runtime consumer; DSH-native behavior uses the separate explicit code entry. - -## Testing - -Focused tests prepare skills and MCP metadata, prove a static-only 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 CI built-entry acceptance invokes `dsh run` with a GitHub source pinned to the pull request head and observes the copied skill alongside the trusted code and MCP proofs owned by the superseding decision. 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 deleted file mode 100644 index c85aaf44d9..0000000000 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.zh.md +++ /dev/null @@ -1,49 +0,0 @@ -# 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 代码时,这些可移植静态贡献仍需复用现有的 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` 包内的静态贡献子格式:skill 根和一个通用 `.mcp.json`。其包元数据使用 `package.json#dsh.skills` 声明相对 skill 根路径,使用 `package.json#dsh.mcpServers` 声明相对 MCP 文档路径。每条路径都可以离开 `.dsh-plugin` 以复用仓库内容,但必须留在包含该 `.dsh-plugin` 的目录之下;因此,一个嵌套且可选择的插件可以拥有其包上方相邻的子树,却不能访问无关宿主路径。该包还可以声明由[受信任 repository 包决策](2026-08-08-trusted-repository-package-code.md)负责的显式代码入口,并且至少需要一种代码或静态贡献。 - -`.dsh-plugin` 包将已发布的 `@deepseek-ai/dsh-repository-plugin` 包声明为开发依赖,并声明非空 `scripts.prepack` 来调用其 `dsh-plugin-prepare` 可执行文件。在 Git 安装期间,pnpm 会按所选包自身的 manifest(元数据清单)安装该依赖;`prepack` 会在依赖安装后、pnpm 打包选定子目录前运行,即使插件嵌套在另一个包管理器工作区内也不例外。包可以先构建其代码。该辅助程序会校验元数据与源码类型,严格解析 `.mcp.json`,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`;源码 loader 会在导入该包装层前重新校验已安装包的生命周期元数据是否包含辅助命令。仅含静态贡献的包仍会获得无 import 包装层,其中包含规范化 manifest、由服务派生的 `inject` 列表,以及对 `dsh-repository-plugin` Loader builtin 的委托。依赖与 workspace 隔离的设计依据见[Git 源准备修复](../bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。 - -加载 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 负责连接尝试、失败日志、远端工具同步、工具调用和断开。Repository 实例会启用严格启动,因此初始连接、发现或工具注册失败会拒绝 repository Loader generation;非严格的独立 client 则保留“记录日志、Plugin 成功但不注册工具”的行为。 - -未知 MCP 字段会被拒绝。这里有意排除 OAuth、`auth` 对象、`CLAUDE_PLUGIN_ROOT` 和更广泛的 Claude 兼容约定。命令、hook、agent(智能体)、规则和其他外来 manifest 约定不会从静态 repository 布局中推断出来;DSH 原生行为使用显式的受信任 Cordis 入口。Repository 子目录选择与 GitHub 源配置属于[独立应用集成](../feature/2026-07-30-config-only-repository-plugins.md),而不是本静态适配器。 - -## 考虑过的替代方案 - -**从 `main`、`exports` 或 repository 布局中发现入口。** 拒绝,因为静态资源并不表示包的普通入口就是 Cordis 插件。受信任代码通过 `dsh.entry` 显式加载,不属于该静态适配器的职责。 - -**让生成包装模块直接实现 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 会有意收束启动失败,并且不暴露工具。MCP client 改为自行提供显式的严格启动选项,由 repository 适配器为其声明的 server 启用。 - -## 后果 - -- 现有 skill/MCP 仓库可以新增一个很小的 `.dsh-plugin/package.json`,无需移动资源或采用 SDK 项目。 -- 已准备的静态输出是确定性胶水;可选的 `dsh.entry` 和已配置的 repository 生命周期仍是受信任的可执行包管理器输入,而非沙箱。 -- 多个 repository Plugin 通过提供方名称和普通 MCP server-name 唯一性共存;重复名称经现有 registry 失败,并参与 Loader 回滚。 -- Cache 内的源码编辑不会实时出现;必须选择另一个精确 source/ref/path/config。 -- 新增可移植静态贡献类型必须提供显式格式和 DSH 自有运行时消费方;DSH 原生行为使用独立的显式代码入口。 - -## 测试 - -聚焦测试会准备 skill 与 MCP 元数据,证明仅含静态贡献的包装模块不含 import,拒绝 Work IQ 风格的 OAuth 字段,映射 Expo 风格 HTTP 与 DataJunction 风格 stdio 及环境变量,并覆盖缺失变量。真实 Loader 测试通过已注册 builtin 挂载生成包装模块,经 `ctx.skills` 读取其 skill,移除 Loader 条目并观察提供方清理。CI 构建入口验收会使用锁定到 PR(Pull Request)head 的 GitHub 源调用 `dsh run`,并观察已复制的 skill,以及由取代本决策的新决策所负责的受信任代码与 MCP 验证证据。 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml index f9e1b3c024..29f0dd7d88 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.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-08-03-per-session-agent-presets.md -2026-08-03-per-session-agent-presets.md: c39117ab0de001650a95f98ccf3e42f3a5034c92 -2026-08-03-per-session-agent-presets.zh.md: 5e98a2865013a355c134317ded8a4f2ddaccf42c +2026-08-03-per-session-agent-presets.md: 5a82f0220058c10892b819a83499c817aa9be6ad +2026-08-03-per-session-agent-presets.zh.md: 0fb3330014eb703c854f70c9e5ed552256651c31 diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md index c39117ab0d..5a82f02200 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.md @@ -18,7 +18,7 @@ Composition splits into two planes, decided by what must be shared rather than b | Plane | Instances | Contents | |---|---|---| -| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), and the web host | +| Host | one | The registries themselves (`tools`, `systemPrompt`, `agents`, `agent-loop`, `sessions`), cross-session facilities (persistence, query, projections, storage, settings, credentials, telemetry), the subagent providers those facilities resolve, and the web host | | Agent | one per session | What a single agent contributes to those registries: tool plugins, persona and prompt sections, compaction policy | Model routing stays out of presets. `installAgentLlmTarget` is already the per-agent seam for provider, model, and reasoning effort, and an LLM adapter mounted inside a preset would never be resolved by `agent-loop`, which lives in the host plane. @@ -55,7 +55,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Authoring a preset is an RPC, and a privileged one.** A composition is a file, but "edit it on the filesystem" is not a browser affordance, so the roster gained `read`/`write`/`remove` beside `select`. Those three are loopback-pinned: a composition names the plugins a session runs, so reading one is reconnaissance and writing one is arbitrary capability. `list` and `select` deliberately stay ordinary. The roster carries ids and trust only, and a LAN client's picker needs it; and choosing a preset looked like escalation — one of them mounts the toolset that edits the live runtime — but `session.create` already takes an `agentPreset`, so pinning only the switch would have left the same capability one method over. The capability is not the preset's to grant either: the deployment's own default already carries `bash` and the filesystem tools, so any caller that may start a session at all can already run commands as this process. Containment is a property of the id (`[a-z0-9][a-z0-9-]*`), checked before it becomes a directory name rather than by inspecting the joined path afterwards; the text is parsed with the loader's own schema and dialect, so a save cannot leave a file no session could load. Shipped presets are refused for writes and deletes, because the deployment's copy is what a broken local preset is compared against — which also makes "duplicate, then edit" the authoring path rather than an afterthought. -**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and its backends are host-plane; the preset contributes the delegation TOOLS, which resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. +**A service with a consumer outside the agent plane cannot move into a preset.** The aggressive split moved the `subagents` registry and its spawn/fork backends into the delegation group's entry-local realm, and `dsh web` then failed to boot: `dsh-host-apiproxy` is a HOST row that injects `subagents` to answer the browser's cross-session queries (`listChildren`, `followup`), so it waited forever for a service only sessions now provided. A per-session copy is wrong twice over — a provider name registers once, so the second session would have collided anyway. The registry and every shared backend, including the [fixed Codex and Claude Code product providers](2026-08-10-product-subagent-providers-in-shared-host.md), are host-plane; a preset contributes whichever delegation TOOLS its agent should see, and those tools resolve the host registry. `workflows` stays entry-local because nothing outside an agent reads it. Grepping injectors is what should have caught this and did not: the search has to include the host packages, not just the agent-plane ones. **A real-composition test that disables a host row cannot audit that row.** The web composition test disabled `api-gateway` — the api-proxy itself — as a row with side effects, which is exactly the row whose pending injection would have named the break. It now boots with the api-proxy enabled and the browse directory picker substituted, so the boot audit covers the whole host-plane injection graph; only the port, the asset tree, and the telemetry exporter stay off. @@ -78,3 +78,7 @@ Which preset an unnamed session gets is a user setting (`agent-presets.default`) **Make the agent's scope key the preset.** Sessions on one preset would share a layer for free, but per-agent registrations — `installAgentLlmTarget`, per-agent tool restrictions — would then collide across sessions. **Run each preset as a child process.** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) already proves a full child harness works, and isolation would be absolute. It also means proxying streaming, approvals, and projections per session, which is a transport project rather than a composition one. + +**Give product subagents global enable settings and a separate settings page.** The process-wide value would compete with the preset as owner of model-visible tools and could not express two sessions using different compositions. Product providers stay host-side, while ordinary preset rows independently expose Codex and Claude Code tools. + +**Ship one preset for every Codex and Claude Code combination.** Four identities duplicate the full preset composition to represent two independent rows. A copied preset can enable either row directly, so combination presets add roster and maintenance cost without adding a user result. diff --git a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md index 5e98a28650..0fb3330014 100644 --- a/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-03-per-session-agent-presets.zh.md @@ -18,7 +18,7 @@ Status: implemented | 平面 | 实例数 | 内容 | |---|---|---| -| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测),以及 web 宿主 | +| 宿主 | 一份 | 注册表本身(`tools`、`systemPrompt`、`agents`、`agent-loop`、`sessions`)、跨会话设施(持久化、查询、投影、存储、设置、凭据、遥测)、这些设施所解析的 subagent provider,以及 web 宿主 | | agent | 每会话一份 | 单个 agent 对这些注册表的贡献:工具插件、人设与提示词段落、压缩策略 | 模型路由不进 preset。`installAgentLlmTarget` 已经是 provider、model 与 reasoning effort 的按 agent 可替换点;而挂在 preset 内部的 LLM 适配器永远不会被 `agent-loop` 解析到,因为后者位于宿主平面。 @@ -56,7 +56,7 @@ Status: implemented **创作 preset 是一次 RPC,而且是特权 RPC。** 组装是一个文件,但“去文件系统里改它”并不是浏览器能提供的操作,因此名单在 `select` 之外新增了 `read`/`write`/`remove`。这三者被固定在环回地址:组装指明了一个会话所运行的插件,因此读取它是侦察,写入它是任意能力。`list` 与 `select` 刻意保持为普通方法。名单只携带 id 与信任级别,而局域网客户端的选择器需要它;至于选择本身,它看起来像提权——其中一个 preset 会挂载可编辑活动运行时的工具集——但 `session.create` 本就接受 `agentPreset`,只固定切换会把同一能力留在隔壁一个方法上。这份能力也不由 preset 授予:部署自带的默认 preset 本就带着 `bash` 与文件系统工具,因此任何被允许开启会话的调用方,早已能以本进程的身份执行命令。约束是 id 自身的性质(`[a-z0-9][a-z0-9-]*`),在它成为目录名之前就检查,而不是事后再去审视拼接出的路径;文本使用 loader 自身的 schema 与方言解析,因此保存不会留下任何会话都无法加载的文件。随部署提供的 preset 拒绝写入与删除,因为部署自带的那一份正是用来对照有问题的本地 preset 的——这也让“先复制、再编辑”成为创作路径本身,而非事后补充。 -**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与后端属于宿主平面;preset 贡献的是委派**工具**,它们解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 +**在 agent 平面之外还有消费方的服务,不能搬进 preset。** 激进拆分把 `subagents` 注册表连同 spawn/fork 后端一起搬进了 delegation 组的 entry-local realm,于是 `dsh web` 直接起不来:`dsh-host-apiproxy` 是宿主行,它注入 `subagents` 来回答浏览器的跨会话查询(`listChildren`、`followup`),因而永远等待一个此刻只有会话才提供的服务。按会话各一份在两个层面上都是错的——provider 名只能注册一次,第二个会话本来也会相撞。注册表与所有共享后端,包括[固定的 Codex 与 Claude Code 产品 provider](2026-08-10-product-subagent-providers-in-shared-host.md),都属于宿主平面;preset 只贡献自己的 agent 应看见的委派**工具**,这些工具解析宿主注册表。`workflows` 保持 entry-local,因为 agent 之外没有任何东西读它。本该拦下它的是「检索注入方」这一步,而它没拦住:检索必须覆盖宿主包,而不只是 agent 平面的包。 **真实组装测试若禁用了某个宿主行,就无法审计该行。** web 组装测试把 `api-gateway`——也就是 api-proxy 本身——当作「有外部副作用的行」禁用了,而它恰恰是那个会以 pending 注入点名此次断裂的行。现在它在启用 api-proxy、并替换为 browse 目录选择器的前提下引导,启动审计因此覆盖整个宿主平面的注入图;只有端口、资源目录与遥测导出器仍然关闭。 @@ -79,3 +79,7 @@ Status: implemented **把 agent 的 scope 键设为 preset。** 同一 preset 上的会话就能免费共享一层,但按 agent 的注册——`installAgentLlmTarget`、按 agent 的工具限制——会跨会话相撞。 **把每个 preset 作为子进程运行。** [`subagent-dsh-sdk`](../../../../packages/subagent/subagent-dsh-sdk/README.md) 已经证明完整的子 harness 可行,隔离性也会是绝对的。但这同时意味着要按会话代理流式输出、审批与投影,那是一个传输层项目,而非组装问题。 + +**给产品 subagent 增加全局启用设置与独立设置页。** 进程级值会与 preset 争夺模型可见工具的所有权,也无法表达两个会话使用不同组装。产品 provider 留在宿主,普通 preset 行分别暴露 Codex 与 Claude Code 工具。 + +**为 Codex 与 Claude Code 的每种组合交付一份 preset。** 四个身份会复制完整 preset 组装,只为表示两条独立行。复制后的 preset 已能直接启用任一行,因此组合 preset 只增加名单与维护成本,不增加用户结果。 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml index 938e802716..c583d6f373 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.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-08-05-profile-plugin-bundles.md -2026-08-05-profile-plugin-bundles.md: 2924b3cb445064fd47d82bcc94ec8d77ded5721b -2026-08-05-profile-plugin-bundles.zh.md: b2287034010bcac1048bb385b2266f1bc75921da +2026-08-05-profile-plugin-bundles.md: 54626e3f48a2ba7db19813e6e883f0e77499d0e2 +2026-08-05-profile-plugin-bundles.zh.md: 357e0f63d4eba0f0985c9e14aad54595c7b41c77 diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md index 2924b3cb44..54626e3f48 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.md @@ -10,11 +10,9 @@ The `dsh` launcher hardcoded its compositions: `base.cordis.yml` + `web.cordis.y ## Decision -Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer, then `--patch` overlays, then flag patches — one `applyEntryPatches` call, identical for boot, flag derivation, and `--dump-config`. +Everything becomes a **profile**: a directory `$DSH_HOME/profiles/` with a `package.json` (pnpm-managed out-of-tree plugin `dependencies` plus the profile manifest `dsh.profile` with its ordered `bundles` layer list) and a user `cordis.patch.yml`. A **bundle** is an npm package declaring `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }`; the two manifest kinds live under distinct `dsh.profile` / `dsh.bundle` keys so a package.json states which role it plays. The tree composes over an empty root by applying each bundle's patch in `dsh.profile.bundles` order, then the user layer and `--patch` overlays — one `applyEntryPatches` call shared by boot and `--dump-config`. App invocation values later moved from launcher-derived patches to startup services in the [app-owned command-line decision](2026-08-06-app-owned-command-line.md). -The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). `dsh web` is the Web-flag alias for `--profile web`; `dsh run [--profile ] "task"` owns one-shot execution and defaults to the headless profile; generic `dsh --profile ` boots without a task. Patch overlays use `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. - -The [`dsh run` command decision](../feature/2026-08-08-dsh-run-headless-command.md) owns the one-shot grammar; this note owns the profile composition it selects. +The shipped bundles are `@deepseek-ai/dsh-base` (shared core rows), `@deepseek-ai/dsh-web-app` (browser Host rows and Web runtime glue), and `@deepseek-ai/dsh-headless` (a direct one-shot runner over base, without web-app). Generic `dsh --profile ` hands its remaining arguments to that profile's command-line startup row: Web owns its flag family, while headless owns its task positional. Patch overlays use launcher-owned `--patch`. `dsh plugin --profile ` is a thin pnpm forwarder that initializes the profile and reconciles `dsh.profile.bundles` with installed bundle declarations; a package without a bundle declaration remains a plain dependency. [Headless as a direct core entry point](2026-08-09-headless-direct-core-entry-point.md) owns the headless composition contract. Resolution is two-anchored by construction: `dsh.profile.bundles` names resolve from the dsh installation first, then the profile directory — so in-box bundles always come from the same installation as the running `dsh` and pnpm never manages them — while bare plugin names in patch rows resolve through the profile directory's Node parent-walk into the maintained flat fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). @@ -24,7 +22,7 @@ Two supporting refactors: the webserver's built-in static dist serving became th - **Dependency-scan plus partial `patchOrder`** (the original sketch): scanning `dependencies` for bundles and ordering unlisted ones alphabetically has two sources of truth and an implicit tie-break; one explicit ordered `dsh.profile.bundles` list is smaller and fully deterministic. A raw `pnpm add` inside the profile installs a library without activating any patch — explicit, no spooky scan. - **`link:` entries for in-box bundles**: pnpm cannot version, install, or update a `link:` into the installation, it embeds a machine path in a user file, and it breaks when the installation moves. The two-anchor resolution plus healed symlink fallback gives the same guarantee ("bundles come from the installation") without ceremony. -- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows the launcher patches, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` host hook is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts. +- **A pre-boot `context` module in the bundle manifest** for boot-time values (dist path, flag facts): rejected in favor of pure plugins — the glue is ordinary rows and app-owned startup services, so the composition stays fully dumpable and the manifest stays data-only. The launcher-owned `ctx.headlessIo` host hook is the one host-provided slot, and it is provided in `boot()`'s `prepare` hook, before any config-tree entry mounts. - **Transitive bundle auto-application**: only direct `dsh.profile.bundles` entries contribute layers; a meta-bundle wanting to re-export another bundle's patch must do so explicitly in its own patch file. ## Consequences diff --git a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md index b228703401..357e0f63d4 100644 --- a/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-05-profile-plugin-bundles.zh.md @@ -10,11 +10,9 @@ Status: implemented ## Decision -一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层,然后是 `--patch` overlay,最后是 flag patch——全部收敛为一次 `applyEntryPatches` 调用,启动、flag 派生与 `--dump-config` 使用完全相同的路径。 +一切都变成 **profile**:即目录 `$DSH_HOME/profiles/`,其中包含一个 `package.json`(pnpm 管理的树外插件 `dependencies`,加上 profile manifest `dsh.profile` 及其有序的 `bundles` 层列表)和一份用户 `cordis.patch.yml`。**组合包**(bundle)是声明了 `"dsh": { "bundle": { "patch": "./cordis.patch.yml" } }` 的 npm 包;两种 manifest 分别位于互不相同的 `dsh.profile` / `dsh.bundle` 键下,因此一份 package.json 能说明自己扮演哪种角色。配置树在空的根之上组合:按 `dsh.profile.bundles` 顺序应用每个组合包的 patch,然后是用户层与 `--patch` overlay——启动与 `--dump-config` 共享同一条 `applyEntryPatches` 路径。随后,[应用持有命令行的决策](2026-08-06-app-owned-command-line.md)又把调用期取值从启动器派生的 patch 迁移到了启动服务。 -随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。`dsh web` 是携带 Web flag 家族的 `--profile web` 别名;`dsh run [--profile ] "task"` 负责一次性执行,默认使用 headless profile;通用的 `dsh --profile ` 启动 profile 而不携带任务。patch overlay 使用 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 - -[`dsh run` 命令决策](../feature/2026-08-08-dsh-run-headless-command.md)负责一次性语法;本 Agent Note 负责该语法所选择的 profile 组合。 +随附的组合包是 `@deepseek-ai/dsh-base`(共享核心配置行)、`@deepseek-ai/dsh-web-app`(浏览器 Host 配置行与 Web 运行时粘合层)和 `@deepseek-ai/dsh-headless`(直接叠加在 base 上且不含 web-app 的一次性 runner)。通用的 `dsh --profile ` 把剩余参数交给该 profile 的命令行启动行:Web 持有自己的 flag 家族,headless 则持有任务位置参数。patch overlay 使用启动器持有的 `--patch`。`dsh plugin --profile ` 是一层薄薄的 pnpm 转发器,负责初始化 profile,并依据已安装包的组合包声明调和 `dsh.profile.bundles`;没有组合包声明的包保持为普通依赖。[Headless 作为直接 core 入口](2026-08-09-headless-direct-core-entry-point.md)负责 headless 组合约定。 解析在构造上就是双锚点的:`dsh.profile.bundles` 中的名称先从 dsh 安装目录解析,再从 profile 目录解析——因此内置组合包始终来自与运行中 `dsh` 相同的安装,pnpm 从不管理它们——而 patch 行中的裸插件名称经 profile 目录的 Node 父目录逐级查找,落到受维护的扁平回退目录 `$DSH_HOME/profiles/node_modules`(安装目录的应用与各组合包所依赖的每个包各一个符号链接,每次启动时修复)。 @@ -24,7 +22,7 @@ Status: implemented - **依赖扫描加部分 `patchOrder`**(最初的草案):扫描 `dependencies` 找出组合包、未列出者按字母序排列,会产生两个真源和一条隐式决胜规则;一份显式有序的 `dsh.profile.bundles` 列表更小、完全确定。在 profile 内直接 `pnpm add` 只会安装一个库,不激活任何 patch——行为显式,没有暗中扫描。 - **内置组合包使用 `link:` 条目**:pnpm 无法对指向安装目录的 `link:` 做版本管理、安装或更新,它会把机器路径嵌进用户文件,并且在安装目录移动后失效。双锚点解析加上每次启动修复的符号链接回退提供了同样的保证(「组合包来自安装目录」),且没有这些繁文缛节。 -- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是启动器 patch 的普通配置行,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。 +- **在组合包 manifest 中放一个启动前 `context` 模块**承载启动期取值(dist 路径、flag 事实):否决,改用纯插件——粘合逻辑就是普通配置行和由应用持有的启动服务,因此组合始终可完整 dump,manifest 保持纯数据。启动器持有的 `ctx.headlessIo` 宿主钩子是唯一由宿主提供的 slot,且在任何配置树条目挂载之前,于 `boot()` 的 `prepare` 钩子中提供。 - **组合包的传递式自动应用**:只有直接列在 `dsh.profile.bundles` 中的条目才贡献层;想重新导出另一个组合包 patch 的元组合包,必须在自己的 patch 文件中显式完成。 ## Consequences diff --git a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml similarity index 58% rename from .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.i18n.yaml index 730f57e681..15abf1380b 100644 --- a/.agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.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 .agents/notes/implemented/feature/2026-08-08-dsh-run-headless-command.md -2026-08-08-dsh-run-headless-command.md: ed095f4077a23e51bffb647d24eed19ba09e11ed -2026-08-08-dsh-run-headless-command.zh.md: 89d54e35573f14786e05d648f2b42891ca27a043 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md +2026-08-06-app-owned-command-line.md: 4a05cac5ed7f44fb55c2d4498bf28a43befdb073 +2026-08-06-app-owned-command-line.zh.md: 86a37f416d17c4615152b29d73f171803f24c4c3 diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md new file mode 100644 index 0000000000..4a05cac5ed --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.md @@ -0,0 +1,51 @@ +# Agent Note: Apps own their command line through `ctx.cmdlineArgs` + +Status: implemented + +English | [中文](2026-08-06-app-owned-command-line.zh.md) + +## Problem + +After profiles, compositions were installable but their command lines were not. `apps/cli` still declared the Web flag family (`--host`, `--port`, `--dev`, `--workspace-root`, `--trusted-host`) and the one-shot task positional, then derived patches for row ids it hardcoded (`webserver`, `api-gateway`, `connection`, `web-runtime`). An out-of-tree app such as [turtle-ui](https://github.com/deepseek-harness/turtle-ui) could contribute rows but had no way to accept a flag: `dsh --profile tui --resume ` had nowhere to be parsed, and `dsh --profile web --help` printed the launcher's help rather than the web app's. + +## Decision + +The launcher parses only what it owns — `--profile`, `--patch`, the config dumps — and hands **everything after its own flags** to the booted tree verbatim. The split is positional: the first token the launcher does not recognize starts the app's arguments (commander's `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`). A bare `dsh -h`, which has no app to hand the flag to, still prints the launcher's own help. + +The new `@deepseek-ai/dsh-cmdline` package owns the handoff. A launcher calls `provideCmdline(ctx, host)` before any entry mounts, providing `ctx.cmdlineArgs` (whose whole interface is `get(): readonly string[]`) and `ctx.appExit`. Any ordinary app plugin may inject `cmdlineArgs`, call `parseCmdline(ctx, program, plan)` with its own commander program, and provide the returned value as an app-owned service. Its Loader row carries no launcher marker or special kind, and the launcher does not inspect the composition for an owner. Multiple plugins may read the same immutable snapshot; a profile with no reader ignores its app arguments. Rows configured from a provider inject its service and read direct lazy config expressions (`port: !!js ctx.webStartup.port ?? 3080`), so a flag beats the value written beside it and nothing is written back into any row. + +The boot mounts the composition once. Cordis holds each row until its injections are active; Loader then interpolates that row's `!!js` against the injection-ready plugin context immediately before activation. Include keeps nested row expressions raw until their target row reaches this point. `--help` leaves the provider's service absent, so dependent rows never activate, and a live patch reload interpolates again against the service that remains active, so a served port cannot be silently reset. + +The shipped apps moved their flags into their bundles: `dsh-web-app` owns the Web family (and enables the `client-hmr` row it now ships disabled, for `--dev`), and `dsh-headless` owns the task positional and rejects a missing task as a usage error. `apps/cli/src/web.ts` is gone; `runProfile` no longer knows any flag-target row id. Out of tree, turtle-ui gained `--resume ` / `--session ` the same way, which is the design's real validation: an installed plugin added a flag with no launcher change. + +Two further consequences. Loader mounts sibling rows concurrently, so one row can activate while another still mounts or while the whole boot is rolling back; the Web bundle therefore publishes its URL only after its own Loader tree settles. The Web bundle's runtime plugin owns the harness-source prompt section too, so `dsh web` and `dsh --profile web` boot identically without Web-specific launcher setup. + +## Why Loader owns the ordering + +Four framework facts shape the mechanism: + +- **A profile's rows arrive inside the root include's `patches` option.** Include is an entry-tree owner, so its static entry-config resolver interpolates Include's own options while preserving nested `!!js` nodes for their target rows instead of recursively evaluating them in the Include context. +- **Cordis activates a fiber only after all declared injections are active.** Immediately before each activation, Cordis runs the `internal/config` waterfall against the fiber's own context; Loader's listener interpolates the raw config after Cordis snapshots its injected services. +- **Provider replacement and HMR must preserve the same contract.** Fiber reactivation re-runs the waterfall, HMR carries the raw config to the replacement fiber, and a pending row accepts option changes without prematurely evaluating expressions against absent services. +- **A row cannot be inserted from inside a mounting plugin** — `tree.create` returns a prefixed id it then fails to resolve — so a conditional row ships `disabled: true` and an active row enables it (`dsh web --dev` and its reload chain). Enablement is an in-memory Loader override rather than an options rewrite, so Include reapplication cannot silently disable it. The Web bundle also starts client discovery only after enabling the optional row, ensuring the first browser graph already contains its HMR receiver. + +This leaves dependency ordering in Cordis activation and Loader interpolation, which own it. Rows keep their `inject` and config, Loader mounts the composition once, and the launcher only provides argv and process-lifecycle services. + +## Alternatives considered + +- **Writing the resolved values into each row** (a config update per row, plus a patch layer handed back to the launcher so a reload could not undo it): it worked, but it meant patches travelling from an app to the launcher and back, two mechanisms for one fact, and a recycle whose correctness depended on Loader restart internals. The maintainer rejected the round trip; the service the rows read replaced all of it. +- **Releasing rows by clearing their `inject`**: it worked in isolation and failed on the real web tree, because clearing `inject` is exactly what loses the plugin's static injections. The failure is silent until a plugin reads a service it declared. +- **Launcher-managed two-pass mounting**: it can make a provider active before readers are applied, but duplicates the composition, makes ordering a launcher concern, and conceals the Loader defect that nested expressions were evaluated in the include context rather than the target row's injected context. +- **The launcher running each bundle's command function before boot** (no Cordis involvement): strictly earlier than "boot, then help", but it makes app startup a second plugin protocol outside the tree. An ordinary `cmdlineArgs`-injected provider keeps one protocol and remains dumpable and patchable. +- **A launcher-enforced command-line owner**: rejecting zero or multiple readers would arbitrate overlaps such as `-h`, but `get()` is an immutable read and normal composition may need several app-owned services. Plugins therefore share the snapshot and own any parser interaction through ordinary composition. +- **`instanceof CommanderError`**: an out-of-tree plugin brings its own commander copy, so the class identity differs and a printed `--help` was rethrown as a fatal load failure. Commander's control-flow errors are detected structurally instead. + +## Consequences + +- An app's flags, help text, and usage errors live with the rows they configure; adding a flag to an installed plugin needs no launcher change. +- The launcher still recognizes the headless runner for one-shot process lifetime and the telemetry row for its environment switch; neither path interprets app arguments. +- `--help` leaves every row that depends on the provider's service pending and requests bounded exit; unrelated rows may activate concurrently before teardown. +- An app-owned service has no statically declared provider: a bundle shipping consumer rows without that provider fails at settlement with pending entries naming the service, not at load. +- A user patch that replaces a row's whole `config` drops its expressions, and with them the flag's precedence for that row. +- Launcher flags must precede app arguments; a first app argument equal to `web` or `plugin` selects that subcommand instead, `-V`/`--version` remains launcher-owned before that boundary, and the launcher's parser consumes one `--`, so a literal `--` for the app needs `-- --`. +- `--dump-config` never runs app command-line providers, so it prints the composition before any app argument is resolved and rejects an invocation that carries app arguments. diff --git a/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md new file mode 100644 index 0000000000..86a37f416d --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-06-app-owned-command-line.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 应用通过 `ctx.cmdlineArgs` 持有自己的命令行 + +Status: implemented + +[English](2026-08-06-app-owned-command-line.md) | 中文 + +## 问题 + +profile 落地之后,组合可以安装,命令行却不能。`apps/cli` 仍然声明着 Web flag 家族(`--host`、`--port`、`--dev`、`--workspace-root`、`--trusted-host`)和一次性任务位置参数,再为自己硬编码的行 id(`webserver`、`api-gateway`、`connection`、`web-runtime`)派生 patch。像 [turtle-ui](https://github.com/deepseek-harness/turtle-ui) 这样的树外应用能贡献行,却无处接受一个 flag:`dsh --profile tui --resume ` 没有地方可供解析,而 `dsh --profile web --help` 打印的是启动器的 help,而不是 web 应用的 help。 + +## 决策 + +启动器只解析属于自己的部分(`--profile`、`--patch`、配置 dump),并把**自己 flag 之后的一切**原样交给引导起来的配置树。切分按位置进行:启动器不认识的第一个 token 就是应用参数的起点(依靠 commander 的 `passThroughOptions` + `allowUnknownOption` + `helpOption(false)`)。裸的 `dsh -h` 没有可交付的应用,仍然打印启动器自己的 help。 + +新包 `@deepseek-ai/dsh-cmdline` 持有这次交接。启动器在任何条目挂载之前调用 `provideCmdline(ctx, host)`,提供 `ctx.cmdlineArgs`(其全部接口就是 `get(): readonly string[]`)与 `ctx.appExit`。任何普通应用插件都可以注入 `cmdlineArgs`,用自己的 commander program 调用 `parseCmdline(ctx, program, plan)`,再把返回值作为应用自有服务提供出去。它的 Loader 行不携带启动器标记或特殊类型,启动器也不会检查组合中的所有者。多个插件可以读取同一份不可变快照;没有读取方的 profile 会忽略自己的应用参数。由提供方配置的行注入其服务,并在惰性配置表达式中直接读取它(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值,也没有任何东西被写回任何一行。 + +boot 只挂载一次整套组合。Cordis 让每一行等待其注入激活;Loader 随后在激活前一刻,基于已注入就绪的插件上下文插值该行的 `!!js`。Include 会保留嵌套的行表达式,直到目标行到达这一时点。`--help` 会让提供方服务保持缺失,因此依赖行永不激活;活动 patch 重载会针对仍然在线的服务再次插值,所以已经服务中的端口不会被悄悄重置。 + +已交付的各应用把自己的 flag 搬进了组合包:`dsh-web-app` 持有 Web 家族(并为 `--dev` 启用它如今以禁用状态交付的 `client-hmr` 行),`dsh-headless` 持有任务位置参数,缺少任务时按用法错误拒绝。`apps/cli/src/web.ts` 已删除;`runProfile` 不再知道任何 flag 目标行 id。在树外,turtle-ui 以同样的方式获得了 `--resume ` / `--session `,这才是这套设计的真正验证:一个已安装的插件加上了一个 flag,启动器毫无改动。 + +还有两条后果。Loader 会并发挂载兄弟行,因此一行可能已经激活,而另一行仍在挂载,或整次 boot 正在回滚;所以 Web 组合包只会在自身的 Loader 配置树结算后公布 URL。另外,Web 组合包的运行时插件也持有 harness 源码提示词段,因此 `dsh web` 与 `dsh --profile web` 无需 Web 专用启动器设置即可按完全相同的方式启动。 + +## 为什么由 Loader 持有顺序 + +四条框架事实塑造了这套机制: + +- **profile 的各行位于根 include 的 `patches` 选项内部。** Include 是条目树所有者,因此它的静态条目配置解析器会插值 Include 自身的选项,同时为目标行保留嵌套的 `!!js` 节点,而不是在 Include 上下文中递归求值。 +- **Cordis 只在所有声明的注入都已激活后才激活 fiber。** 每次激活前一刻,Cordis 会基于 fiber 自身上下文运行 `internal/config` waterfall;Cordis 快照注入服务之后,Loader 的监听器再插值原始配置。 +- **提供方替换与 HMR 必须保持相同契约。** fiber 重新激活时会重跑 waterfall,HMR 会把原始配置带给替换 fiber,而待处理行可以接受选项变更,不会针对缺失服务提前求值表达式。 +- **不能从正在挂载的插件内部插入一行**——`tree.create` 返回一个带前缀的 id,随后它自己解析不出来——因此条件性的行以 `disabled: true` 交付,再由活跃行启用(`dsh web --dev` 及其重载链路)。启用采用 Loader 的内存覆盖而非改写选项,因此 Include 重新应用配置时不会悄然将其禁用。Web 组合包还会在启用可选行之后才启动客户端发现,确保首份浏览器图中已经包含 HMR 接收端。 + +这样,依赖顺序仍由负责它的 Cordis 激活与 Loader 插值流程处理。各行保留自己的 `inject` 和配置,Loader 只挂载一次组合,启动器只提供 argv 与进程生命周期服务。 + +## 曾考虑的替代方案 + +- **把解析出的取值写进每一行**(逐行一次配置更新,外加交还给启动器的一层 patch,使重载无法撤销它):它能工作,但这意味着 patch 在应用与启动器之间来回传递、同一件事有两套机制,以及一套其正确性依赖 Loader 重启内部细节的回收重建。维护者否决了这次往返;供各行读取的服务取代了这一切。 +- **通过清空行的 `inject` 来放行**:孤立测试可行,在真实 web 树上失败,因为清空 `inject` 恰恰会丢失插件的静态注入。在插件真的去读它声明过的服务之前,这个失败是静默的。 +- **由启动器管理两趟挂载**:它可以让提供方先于读取行激活,但会重复组合、把顺序变成启动器职责,还掩盖了 Loader 的缺陷——嵌套表达式在 include 上下文而不是目标行的注入上下文中求值。 +- **由启动器在 boot 之前运行每个组合包的命令函数**(完全不经过 Cordis):严格早于「先 boot 再 help」,但这会让应用启动成为配置树之外的第二套插件协议。使用注入 `cmdlineArgs` 的普通提供方只保留一套协议,并且仍可 dump、可 patch。 +- **由启动器强制指定命令行所有者**:拒绝零个或多个读取方可以裁决 `-h` 等重叠项,但 `get()` 是不可变读取,普通组合也可能需要多个应用自有服务。因此插件共享该快照,并通过普通组合持有各自解析器的交互。 +- **`instanceof CommanderError`**:树外插件会带来自己的一份 commander 副本,类身份因此不同,已经打印出来的 `--help` 会被重新抛成致命的加载失败。改为按结构识别 commander 的控制流错误。 + +## 后果 + +- 应用的 flag、help 文本和用法错误与它们所配置的行放在一起;给已安装的插件加一个 flag 不需要改动启动器。 +- 启动器仍会识别 headless runner 以管理一次性进程生命周期,并识别 telemetry 行以应用环境开关;两条路径都不解析应用参数。 +- `--help` 会让所有依赖提供方服务的行保持待处理并请求有边界的退出;无关行可能在拆除前并发激活。 +- 应用自有服务没有静态声明的提供方:交付了消费行却缺少对应提供方的组合包会在结算时失败,报出指向该服务的待处理条目,而不是在加载时失败。 +- 用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉,该行上 flag 的优先级也随之消失。 +- 启动器的 flag 必须写在应用参数之前;如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令;`-V`/`--version` 在该边界之前仍归启动器持有;而且启动器的解析器会消耗掉一个 `--`,因此要给应用传一个字面量 `--` 需要写成 `-- --`。 +- `--dump-config` 从不运行应用命令行提供方,因此它在任何应用参数被解析之前打印组合,并拒绝携带应用参数的调用。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md deleted file mode 100644 index 387479b3b3..0000000000 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note: Trusted repository packages load Cordis code - -Status: implemented - -English | [中文](2026-08-08-trusted-repository-package-code.zh.md) - -## Problem - -The standalone repository format already installs a selected Git package and runs its dependency and lifecycle code with host authority, but it exposed only copied skills and MCP metadata to DSH. Forbidding a Cordis entry did not create a security boundary: package installation remained trusted executable code while the restriction prevented the package from contributing the Plugin behavior that the Harness architecture is designed to compose. - -A repository author also needs to keep an ordinary TypeScript npm package shape. Requiring publication to npm, pre-generated JavaScript in Git, or a DSH-owned TypeScript compiler would make a Git source less capable than the same package installed through a developer-owned SDK project. The first model request must observe any MCP tools that this package starts; background-only initial discovery makes a successful installation nondeterministic at the application boundary. - -## Decision - -A configured repository package is trusted code. Its `.dsh-plugin/package.json` may declare `dsh.entry` as a relative path to a compiled ESM Cordis Plugin inside that package, alongside or instead of `dsh.skills` and `dsh.mcpServers`. At least one contribution is required. The entry may use namespace exports or a default export and retains ordinary Cordis semantics for `name`, `inject`, `Config`, registrations, startup failure, and effect-scoped teardown. - -The package owns its npm dependencies and build toolchain. It declares the published `@deepseek-ai/dsh-repository-plugin` package to obtain the `dsh-plugin-prepare` executable. `scripts.prepack` is a non-empty package-authored command that must invoke that dependency-provided helper, but it may first run `tsc`, `tsdown`, or any other build. DSH neither injects the helper, parses the shell program, nor compiles repository source. The helper validates the metadata after the preceding build, requires the configured entry to resolve to a file within `.dsh-plugin`, validates and copies declared static assets, and writes the prepared `dsh-plugin.mjs` wrapper. The installed package must retain a `prepack` declaration containing that helper command; a missing dependency, wrapper, or build output fails before a cache generation becomes usable. - -The generated wrapper first mounts the DSH-owned static runtime for skills and MCP definitions, then dynamically imports and unwraps the explicit entry and mounts it as a child. The wrapper statically declares dependencies implied by the prepared manifest; an entry module's additional `inject` is discovered only when mounted and must already be available in the host composition. Both children must reach Cordis `ACTIVE`; an unsatisfied `inject` or startup exception rejects the repository Loader transaction instead of committing an inert generation. Loader removal, failed replacement, and parent disposal unwind the entry, skill providers, MCP clients, and their effects together. - -`dsh-mcp-client` resolves its initial connection and tool synchronization promise as part of Plugin application. Its entry is an `async function`, not an ordinary function returning a Promise: Cordis identifies prototype-bearing ordinary functions as constructors and does not treat a constructor's returned Promise as startup work. A valid server's tools therefore exist before its parent repository wrapper activates and before a one-shot application starts its first model request. Its `failOnStartupError` config preserves optional standalone servers by default while letting repository adapters require their declared servers. Repository-translated MCP clients enable that mode, so initial connection, discovery, or tool-registration failure rejects the candidate generation and rollback still closes the transport. - -## Trust boundary - -Exact refs, source containment, credential-shaped environment scrubbing, prepared manifests, and immutable cache keys protect identity and composition integrity; they do not sandbox executable package input. Repository lifecycle scripts, transitive npm dependencies, the compiled entry, and spawned MCP servers can exercise the authority available to the DSH process and the Cordis services they receive. Users must therefore trust the selected repository and should pin immutable refs and grant Git only the narrow read credential needed for acquisition. - -Model-visible behavior remains governed by the owning DSH seam. A repository entry may register tools, prompt sections, policies, commands, agents, or other effects, but anything reaching a model request still needs the corresponding logged DSH representation and lifecycle cleanup. The repository format grants code loading; it does not weaken those service contracts. - -## Alternatives considered - -**Keep code forbidden while allowing arbitrary package lifecycles.** Rejected because installation already executes trusted repository code, so the restriction added no isolation and forced Plugin authors to publish or maintain a second integration path. - -**Have DSH compile repository TypeScript.** Rejected because compiler choice, module layout, generated chunks, native dependencies, and package metadata belong to the npm package. Running the package's declared build preserves the same boundary as other Git dependencies. - -**Import `main`, `exports`, or another discovered entry implicitly.** Rejected because an npm package may contain utilities or an MCP executable that is not a Cordis Plugin. The explicit `dsh.entry` field makes code activation reviewable and lets preparation validate the packed path. - -**Add a closed manifest field for every future DSH contribution.** Rejected as the universal extension mechanism. Skills and common MCP files retain useful portable static adapters, while DSH-native behavior composes through the existing Cordis Plugin and service contracts. - -## Consequences - -- A TypeScript DSH Plugin can live in a GitHub repository, install ordinary npm dependencies, compile during `prepack`, and run without publishing the Plugin package to npm. -- Static-only repository packages remain valid and retain import-free wrappers; adding `dsh.entry` opts that package into runtime code import. -- A package build, dependency install, entry import, unmet service, or Plugin startup failure prevents the candidate generation from replacing the last good configuration. -- Initial MCP synchronization can lengthen application startup by the MCP SDK's per-request timeout, and a repository-declared server that is unavailable or cannot publish its complete tool generation prevents that candidate generation from activating. -- Repository code receives host authority, so source review and immutable pinning are operational security requirements rather than optional hardening. - -## Testing - -Repository-format tests prepare and mount default-export code entries through the real Loader, observe an entry-owned service, remove the Loader row, and observe cleanup; they also retain skill/MCP preparation, containment, damaged-package, pending-service, and rollback coverage. MCP lifecycle tests require `apply` to settle only after initial tool publication, preserve opt-in contained startup failure, and prove strict connection or tool-registration rejection still closes the client. - -The Node 24 consumer acceptance uses the actual built `dsh run` command with a fresh DSH home and an authenticated private GitHub source pinned to the pull request's exact head SHA. The test packs the current repository Plugin build with the same private-field removal and workspace-dependency pinning used for publication, serves its packument and tarball from a job-local npm registry, and directs the Git package's ordinary scoped npm resolution there. That repository package obtains `dsh-plugin-prepare` from the simulated published dependency, installs its other pinned runtime and development dependencies, type-checks and bundles TypeScript during `prepack`, prepares a skill plus a stdio MCP server and `dsh.entry`, exposes the skill and MCP schema in the first real model request, executes the MCP tool, and lets the compiled Cordis entry append a second marker to the result observed in the following request. Registry and cache assertions require npm resolution to reach the simulated publication, source files to be absent from the packed installation, and both built modules, their installed dependency, copied assets, and generated wrapper to be present. diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md b/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md deleted file mode 100644 index ecc325c3dd..0000000000 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.zh.md +++ /dev/null @@ -1,51 +0,0 @@ -# Agent Note: 受信任 repository 包加载 Cordis 代码 - -状态:已实现 - -[English](2026-08-08-trusted-repository-package-code.md) | 中文 - -## 问题 - -独立 repository 格式已经会安装选定的 Git 包,并以宿主权限运行其依赖和生命周期代码,但它向 DSH 暴露的只有复制后的 skill(技能)和 MCP 元数据。禁止 Cordis 入口并未建立安全边界:包安装过程仍会执行受信任代码,而这项限制却阻止包贡献 Harness 架构本就用于组合的插件行为。 - -仓库作者还需要保持普通 TypeScript NPM 包的结构。如果要求发布到 NPM、把预生成的 JavaScript 签入 Git,或使用 DSH 自有的 TypeScript 编译器,Git 源的能力就会弱于通过开发者自有 SDK 项目安装的同一个包。首个模型请求必须看到该包启动的所有 MCP 工具;仅在后台进行初始发现,会让一次成功安装在应用边界上具有不确定性。 - -## 决策 - -已配置的 repository 包是受信任代码。其 `.dsh-plugin/package.json` 可以连同 `dsh.skills` 和 `dsh.mcpServers` 声明 `dsh.entry`,也可以用它取代二者;`dsh.entry` 是指向该包内已编译 ESM Cordis 插件的相对路径。至少需要一种贡献。入口可以使用 namespace 导出或 default export,并沿用 Cordis 对 `name`、`inject`、`Config`、注册、启动失败和 effect 作用域清理的常规语义。 - -包自行负责其 NPM 依赖和构建工具链。它声明已发布的 `@deepseek-ai/dsh-repository-plugin` 包以取得 `dsh-plugin-prepare` 可执行文件。`scripts.prepack` 是由包作者编写的非空命令,必须调用该依赖提供的辅助程序,但可以先运行 `tsc`、`tsdown` 或其他任意构建。DSH 不会注入辅助程序,也不会解析该 shell 程序或编译 repository 源码。辅助程序会在前序构建之后校验元数据,要求已配置入口解析到 `.dsh-plugin` 内的文件,校验并复制已声明的静态资源,再写入已准备的 `dsh-plugin.mjs` 包装层。已安装包必须保留包含该辅助命令的 `prepack` 声明;依赖、包装层或构建输出缺失会在缓存 generation 可用前导致失败。 - -生成的包装层先挂载 DSH 自有的静态运行时来处理 skill 和 MCP 定义,再动态导入显式入口、解包其导出并将其挂载为子级。包装层会静态声明已准备 manifest(元数据清单)所隐含的依赖;入口模块的额外 `inject` 只有在挂载时才会被发现,并且此时必须已存在于宿主组合中。两个子级都必须进入 Cordis `ACTIVE`;无法满足的 `inject` 或启动异常会拒绝 repository Loader 事务,而不会提交未激活的 generation。Loader 移除、替换失败和父级 dispose(资源释放)会一并撤销入口、skill 提供方、MCP client 及其 effect。 - -`dsh-mcp-client` 会在插件应用期间完成其初始连接和工具同步 promise。其入口必须是 `async function`,而不是返回 Promise 的普通函数:Cordis 会把带 prototype 的普通函数识别为 constructor,不会把 constructor 返回的 Promise 当作启动工作。因此,有效 server 的工具会在父级 repository 包装层激活前、一次性应用发起首个模型请求前就已存在。其 `failOnStartupError` 配置默认保留独立可选 server 的行为,同时允许 repository adapter 要求已声明 server 必须可用。Repository 转换出的 MCP client 会启用该模式,因此初始连接、发现或工具注册失败会拒绝候选 generation,回滚仍会关闭 transport。 - -## 信任边界 - -精确 ref、源路径包含约束、清除名称符合凭据模式的环境变量、已准备的 manifest 和不可变缓存键,可以保护身份与组合完整性;它们不会为可执行包输入提供沙箱隔离。Repository 生命周期脚本、传递性 NPM 依赖、已编译入口和 spawn 的 MCP server 可以行使 DSH 进程可用的权限,以及它们所获 Cordis 服务授予的权限。因此,用户必须信任所选仓库,应当固定不可变 ref,并只授予 Git 获取源码所需的最小只读凭据。 - -模型可见行为仍由所属 DSH seam 管理。repository 入口可以注册工具、提示词段落、策略、命令、agent(智能体)或其他 effect,但任何进入模型请求的内容仍须具有对应的 DSH 日志表示和生命周期清理。repository 格式授予代码加载能力;它不会削弱这些服务约定。 - -## 考虑过的替代方案 - -**继续禁止代码,但允许任意包生命周期。** 拒绝,因为安装过程本就执行受信任的 repository 代码,所以该限制没有提供隔离,反而迫使插件作者发布或维护第二条集成路径。 - -**由 DSH 编译 repository TypeScript。** 拒绝,因为编译器选择、模块布局、生成分片、原生依赖和包元数据属于 NPM 包。运行包所声明的构建,可以保持与其他 Git 依赖相同的边界。 - -**隐式导入 `main`、`exports` 或其他发现的入口。** 拒绝,因为 NPM 包可能包含并非 Cordis 插件的实用工具或 MCP 可执行文件。显式 `dsh.entry` 字段使代码激活可供评审,并让准备阶段校验打包后的路径。 - -**为未来每种 DSH 贡献添加封闭 manifest 字段。** 不采用它作为通用扩展机制。skill 和通用 MCP 文件仍保留有用的可移植静态适配器;DSH 原生行为则通过现有 Cordis 插件与服务约定组合。 - -## 后果 - -- TypeScript DSH 插件可以存放在 GitHub 仓库中,安装普通 NPM 依赖,在 `prepack` 期间完成编译,并在无需把插件包发布到 NPM 的情况下运行。 -- 仅含静态贡献的 repository 包仍然有效,并保留无 import 包装层;添加 `dsh.entry` 会使该包选择启用运行时代码导入。 -- 包构建、依赖安装、入口导入、所需服务未满足或插件启动失败,都会阻止候选 generation 替换最后一个可用配置。 -- 初始 MCP 同步可能因 MCP SDK 的单次请求超时而延长应用启动时间;repository 声明的 server 不可用或无法发布完整工具 generation 时,该候选 generation 无法激活。 -- Repository 代码获得宿主权限,因此源码评审和锁定不可变 ref 是运行安全要求,而不是可选加固措施。 - -## 测试 - -repository 格式测试通过真实 Loader 准备并挂载使用 default export 的代码入口,观察入口自有服务,移除 Loader 配置项,再观察清理;测试还保留针对 skill/MCP 准备、路径包含约束、包损坏、等待服务和回滚的覆盖。MCP 生命周期测试要求 `apply` 只在初始工具发布后完成,保留可选择启用的启动失败收束行为,并证明严格连接拒绝或工具注册拒绝仍会关闭 client。 - -Node 24 消费方验收使用实际构建的 `dsh run` 命令、全新 DSH 主目录,以及锁定到 PR(Pull Request)的精确 head SHA 且经过认证的私有 GitHub 源。测试会采用发布时相同的移除 `private` 字段和固定 workspace 依赖版本流程,对当前 repository 插件构建进行打包;再由作业本地 NPM 注册表提供其 `packument` 与 tarball,并把 Git 包的常规 scoped NPM 解析指向该注册表。该 repository 包从模拟发布的依赖取得 `dsh-plugin-prepare`,安装其他固定版本的运行时依赖与开发依赖,在 `prepack` 期间对 TypeScript 进行类型检查和打包,准备一个 skill、一个 stdio MCP server 及 `dsh.entry`,在首个真实模型请求中暴露 skill 与 MCP schema,执行 MCP 工具,并让已编译 Cordis 入口向结果追加第二个标记,供后续请求观察。注册表与缓存断言要求 NPM 解析必须命中模拟发布,打包安装中不存在源码文件,同时必须存在两个已构建模块、其已安装依赖、复制资源和生成包装层。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml index b76951347b..42600f1c56 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.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-08-09-client-conversation-node-assembly.md -2026-08-09-client-conversation-node-assembly.md: 16a39539064644e5467f701789a7e2ef1f7ff172 -2026-08-09-client-conversation-node-assembly.zh.md: 0e0fbdf8f3320393022528e6e3fe2cf0d492a1d3 +2026-08-09-client-conversation-node-assembly.md: f6cd7ea94d485d92fd4ea178751c08bd01ecb4b2 +2026-08-09-client-conversation-node-assembly.zh.md: e550b7870a611ec625d7c2a738bf71947825ea58 diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md index 16a3953906..f6cd7ea94d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.md @@ -148,7 +148,7 @@ The Assembler verifies `node.key === context.key` and `node.target === target`. `current` lets a Definition distinguish "never materialized" from "already materialized and now hidden." Assistant retry and Turn Error suppression use it to avoid illegal Node withdrawal. -A Definition may branch by target to construct different data, while matching, Context identity, and State remain target-neutral. This change registers only the `chat` builder; Trajectory remains on its independent `session-history` fold until it gains a registered target. +A Definition owns at most one view target; state-only Definitions omit both `target` and `buildViewNode()`. Chat and Trajectory register separate business Definitions even when they recognize the same durable Event family, while the shared Assembler supplies the same matching, replay, Location, and publication mechanics to both targets. #### No generic `end()` @@ -328,7 +328,9 @@ When business logic deliberately changes a materialized Node to hidden, it leave The concrete Tool renderer remains governed by the [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md). Tool Definition supplies recursive root/subcall data, and `ui-tool` dispatches concrete presentation by the Tool-name keyed slot. -Trajectory has no registered target and does not consume the Chat Builder's legacy slice. Its activated `SessionHistoryInspection` keeps an independent history fold, while the ordinary Session snapshot no longer runs a second transcript fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; a future Trajectory migration does not change the Event Definition, Context, Reader, or Location contracts. +Trajectory registers its own target and business Definitions against the same Assembler and Session event window as Chat. Its target builder preserves the stage-oriented read model without consuming the Chat Builder's legacy slice or running an independent history fold. The Chat Builder retains its legacy slice for StatsLine and the top-level public compatibility fields; target-specific Definitions do not change the shared Context, Reader, or Location contracts. + +The target-specific Trajectory Definitions, retained stage model, Steering adaptation, complexity bounds, and presentation hot paths are owned by the [Trajectory Context assembly decision](2026-08-11-trajectory-conversation-context-assembly.md). ## Runtime and render path @@ -339,20 +341,17 @@ Session Event window -> Context matches + State + Location -> Definition.buildLocationData(step -> turn) -> StepLocation.data / TurnLocation.data - -> Definition.buildViewNode(target = chat) - -> ChatSnapshotBuilder - -> order[] + keyed Node store + Location index + timeline - -> ChatView - -> ChatNodeSeat(key) - -> conversation.chat.node(entryKey = node.kind, hookContext = key) - -> slot-level useTurnData(businessKey) + -> Definition.buildViewNode() for its declared target + -> target View Builder + -> chat: ChatSnapshotBuilder -> ChatView -> keyed ChatNodeSeat + -> trajectory: TrajectorySnapshotBuilder -> stages/layout/table ``` ## Verification Runtime tests pin Definition lifecycle registration, exact-ID append, update-before-start collection followed by forward replay after start, prepend identity, Reader window-gap repair, transitive dependencies, Location closure, Step→Turn data phase order, Location data replacement, publication cadence, illegal withdrawal, and per-target Builders. -Conversation tests cover every built-in Definition, Assistant Step data, Turn Tail and Deliverables Turn data, Chat ordering and structural sharing, selector isolation, Assistant and Tool running-to-settled identity, nested Code Dispatch, steering, Compaction, Retry, interruption, load-older anchoring, and slot dispatch. +Conversation tests cover every built-in Chat Definition, Assistant Step data, Turn Tail and Deliverables Turn data, Chat ordering and structural sharing, selector isolation, Assistant and Tool running-to-settled identity, nested Code Dispatch, steering, Compaction, Retry, interruption, load-older anchoring, and slot dispatch. Trajectory tests cover its independently registered Message, Assistant, Tool, Compaction, Request-header, and boundary Definitions together with the preserved stage-oriented view model. Slot type/runtime tests pin required parent-provided common inject, the `hookContext` type, Hook isolation across Node contexts, stable factory/Hook identity, and the absence of business-renderer rerenders for unrelated Session publications. Existing entry-owned Observable Hook tests continue to pin the path that does not use a contextual factory. @@ -382,7 +381,7 @@ History-path tests cover complete replace, non-overlapping prepend, overlapping- **Add generic `end()`, prepared, or window-reset lifecycles.** Rejected: businesses have different completion conditions, and a pagination gap is not a business lifecycle. Business Events update State, Location close triggers replay/build, and Reader dependencies own pagination invalidation. -**Register separate Event Definitions for Chat and Trajectory.** Rejected: identity, State, and Location are target-neutral. `buildViewNode(target)` and each Builder express view differences; Trajectory's independent history fold remains until it registers its own Builder. +**Reuse one Event Definition across Chat and Trajectory by branching in `buildViewNode(target)`.** Rejected: the views require different business State and intermediate records, so a shared Definition would make each package carry the other's conditions and payloads. Separate target-owned Definitions keep those choices local while sharing the Assembler's ingestion and lifecycle contracts. **Add a generic layout model above final business Nodes.** Rejected: activity, tail candidacy, and layout enums would centralize current Chat business semantics in the engine again. Final Nodes carry renderer-required data directly and share only identity, ordering, and Location facts. @@ -406,4 +405,4 @@ Steps and Turns become stable homes for cross-business aggregates. Turn Tail and The cost is new Runtime contracts for Registry, Assembler, Location data, dependency replay, and per-target Builders, plus parent-owned common inject and per-occurrence `hookContext` in UI Slots. Definition authors must understand stable IDs, unique starts, forward replay, Step→Turn publication order, read-only Reader access, and the prohibition on Node withdrawal. -`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, Trajectory still owns an independent history fold, and built-in Definitions currently remain centralized in `ui-conversation`. These compatibility boundaries do not return business interpretation to Session. +`useTurnData()` does not revoke the standard `useSession` capability from session-scoped renderers, so this boundary relies on API guidance and tests rather than capability isolation. Registry changes remain low-frequency full rebuilds; the Chat Builder still maintains a legacy slice for StatsLine and the top-level public fields, while Trajectory owns target-specific Definitions and a Builder over the shared Session window. Built-in Definitions remain in their respective UI packages, and these compatibility boundaries do not return business interpretation to Session. diff --git a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md index 0e0fbdf8f3..e550b7870a 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-client-conversation-node-assembly.zh.md @@ -148,7 +148,7 @@ Assembler 校验 Node `key === context.key` 且 Node `target === target`。业 `current` 让 Definition 区分“从未生成”与“已经生成后需要隐藏”。Assistant retry 和 Turn Error suppression 使用它避免非法的 Node 撤回。 -Definition 可以针对 target 分支构造不同 data,但匹配、Context identity 和 State 保持 target-neutral。本次只注册 `chat` builder;在拥有注册 target 之前,Trajectory 继续使用独立的 `session-history` fold。 +一个 Definition 最多拥有一个 view target;仅维护状态的 Definition 同时省略 `target` 与 `buildViewNode()`。即使 Chat 与 Trajectory 识别同一持久 Event 族,它们也分别注册自己的业务 Definition;共享 Assembler 则为两个 target 提供相同的匹配、replay、Location 与发布机制。 #### 不提供通用 `end()` @@ -328,7 +328,9 @@ Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat 具体 Tool renderer 仍由 [`ui-tool ownership decision`](2026-08-08-client-tool-presentation-ownership.md) 约束。Tool Definition 只交付递归 root/subcall data,`ui-tool` 再按 Tool name keyed slot 分发具体表现。 -Trajectory 尚未注册 target,也不消费 Chat Builder 的 legacy slice。它已激活的 `SessionHistoryInspection` 继续维护独立 history fold,而普通 Session snapshot 不再运行第二套 transcript fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slice;未来迁移 Trajectory 不改变 Event Definition、Context、Reader 或 Location 契约。 +Trajectory 针对与 Chat 相同的 Assembler 和 Session 事件窗口注册自己的 target 与业务 Definition。它的 target builder 保留 stage-oriented read model,既不消费 Chat Builder 的 legacy slice,也不运行独立 history fold。Chat Builder 为 StatsLine 和顶层公共兼容字段保留 legacy slice;target 专属 Definition 不改变共享的 Context、Reader 或 Location 契约。 + +target 专属 Trajectory Definition、保留的 stage model、Steering 适配、复杂度上界与表现层热点由 [Trajectory Context 组装决策](2026-08-11-trajectory-conversation-context-assembly.md)负责。 ## Runtime and render path @@ -339,20 +341,17 @@ Session Event window -> Context matches + State + Location -> Definition.buildLocationData(step -> turn) -> StepLocation.data / TurnLocation.data - -> Definition.buildViewNode(target = chat) - -> ChatSnapshotBuilder - -> order[] + keyed Node store + Location index + timeline - -> ChatView - -> ChatNodeSeat(key) - -> conversation.chat.node(entryKey = node.kind, hookContext = key) - -> slot-level useTurnData(businessKey) + -> Definition.buildViewNode() for its declared target + -> target View Builder + -> chat: ChatSnapshotBuilder -> ChatView -> keyed ChatNodeSeat + -> trajectory: TrajectorySnapshotBuilder -> stages/layout/table ``` ## Verification Runtime tests 固定 Definition 生命周期注册、exact-ID append、update-before-start 收集与 start 后正序 replay、prepend identity、Reader window-gap 修复、传递依赖、Location closure、Step→Turn data phase order、Location data replacement、publication cadence、非法撤回和 per-target Builder。 -Conversation tests 覆盖全部内建 Definition、Assistant Step data、Turn Tail 与 Deliverables Turn data、Chat 排序和结构共享、selector isolation、Assistant/Tool running-to-settled identity、nested Code Dispatch、steering、Compaction、Retry、interruption、load-older anchoring 和 slot dispatch。 +Conversation tests 覆盖全部内建 Chat Definition、Assistant Step data、Turn Tail 与 Deliverables Turn data、Chat 排序和结构共享、selector isolation、Assistant/Tool running-to-settled identity、nested Code Dispatch、steering、Compaction、Retry、interruption、load-older anchoring 和 slot dispatch。Trajectory tests 则覆盖它独立注册的 Message、Assistant、Tool、Compaction、Request-header 与 boundary Definition,以及继续保留的 stage-oriented view model。 Slot type/runtime tests 固定父注册必须提供声明的 common inject、`hookContext` 类型、不同 Node context 的 Hook 隔离、factory/Hook identity 稳定,以及无关 Session publication 不重渲染业务 renderer。原 entry-owned Observable Hook 测试继续固定未使用 contextual factory 的路径。 @@ -382,7 +381,7 @@ Assembled Web snapshot、GUI 和浏览器场景覆盖真实 plugin graph。浏 **增加通用 `end()`、prepared 或 window reset 生命周期。** 拒绝:不同业务完成条件不同,分页缺口也不是业务生命周期。业务 Event 更新 State,Location close 触发 replay/build,Reader dependency 负责补页失效。 -**为 Chat 与 Trajectory 注册两套 Event Definition。** 拒绝:identity、State 和 Location 与 target 无关。视图差异由 `buildViewNode(target)` 和各自 Builder 表达;Trajectory 在注册自己的 Builder 之前继续使用独立 history fold。 +**在同一个 Event Definition 内通过 `buildViewNode(target)` 为 Chat 与 Trajectory 分支。** 拒绝:两种视图需要不同的业务 State 与中间记录,共用 Definition 会迫使每个 package 携带另一边的条件与 payload。target 自有的 Definition 把这些选择留在本地,同时复用 Assembler 的摄入与生命周期契约。 **在最终业务 Node 上再叠一层通用 layout model。** 拒绝:activity、tail candidacy 和 layout enum 会把当前 Chat 的业务语义重新集中到引擎。最终 Node 直接携带 renderer 所需 data,只共享 identity、排序和 Location 事实。 @@ -406,4 +405,4 @@ Step/Turn 成为业务间共享聚合的稳定宿主。Turn Tail 和 Deliverable 代价是 Runtime 新增 Registry、Assembler、Location data、依赖重放和 per-target Builder 契约,UI Slots 也新增 parent-owned common inject 与 per-occurrence `hookContext`。Definition 作者必须理解稳定 ID、唯一 start、正序 replay、Step→Turn 发布顺序、只读 Reader 和 Node 不撤回规则。 -`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsLine 和顶层公共字段维护 legacy slice,Trajectory 继续拥有独立 history fold,内建 Definitions 暂时集中在 `ui-conversation`。这些是兼容边界,不把业务解释权交还给 Session。 +`useTurnData()` 不撤销 session-scoped renderer 的标准 `useSession`,因此该边界依靠 API 引导和测试,而不是能力隔离。Registry 变化仍是低频完整 rebuild;Chat Builder 继续为 StatsLine 和顶层公共字段维护 legacy slice,Trajectory 则在共享 Session 窗口上拥有 target 专属 Definition 与 Builder。内建 Definition 分别留在所属 UI package;这些兼容边界不把业务解释权交还给 Session。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml index b851627050..a5e5d22982 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.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-08-09-headless-direct-core-entry-point.md -2026-08-09-headless-direct-core-entry-point.md: 49afe2993de7302adbedcdf9e8e2347d6424ee2a -2026-08-09-headless-direct-core-entry-point.zh.md: 73c1cbe5ac777025f63f46751b1d5ccebbfe9676 +2026-08-09-headless-direct-core-entry-point.md: b705df2e6d88e096ee3ba50a6156b815dbd98b98 +2026-08-09-headless-direct-core-entry-point.zh.md: 439f4c21a2ce1741e8d483bc307508550da1bec7 diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md index 49afe2993d..b705df2e6d 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.md @@ -20,11 +20,11 @@ The shipped `headless` profile contains `dsh-base` and `dsh-headless`. The headl `loadProfile` recognizes the exact installation-owned headless tuple (`dsh-base`, `dsh-web-app`, `dsh-headless`) and normalizes it to the shipped headless template while preserving every other manifest field. Extra, missing, or reordered bundle lists are user-owned and remain untouched. -This note owns the headless transport and completion contracts. [`dsh run` owns one-shot headless execution](../feature/2026-08-08-dsh-run-headless-command.md) owns the command grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. +This note owns the headless transport and completion contracts. [Apps own their command lines](2026-08-06-app-owned-command-line.md) owns the current `dsh --profile headless` grammar; the former [`dsh run` decision](../../archived/feature/2026-08-08-dsh-run-headless-command.md) records the superseded launcher-owned grammar, [GUI layering and RPC protocol](2026-07-19-gui-layering-and-rpc-protocol.md) owns browser gateway boundaries, [web config-tree boot and transport layering](2026-07-24-web-config-tree-boot-and-transport-layering.md) owns the Web tree, and [the default model follows the picker](../feature/2026-08-07-default-model-follows-the-picker.md) owns persistence of the shared Agent default. ## Verification -Package tests use the real Session store and Agent registry around a scripted Agent factory to pin idle-to-idle aggregation, late asynchronous completion, terminal model diagnostics, other non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshots drive `dsh run` through a replayed tool round trip, record a `user/message` with `source.kind: 'user'`, and expose a terminal model failure on stderr. Built-bin acceptance reaches a mock provider through the published entry and requires final text on stdout, exit 0, and empty stderr. Config-dump acceptance excludes every Host, Web, and Client package from the shipped headless tree; PTY shutdown coverage requires no observation line and bounded disposal. +Package tests use the real Session store and Agent registry around a scripted Agent factory to pin idle-to-idle aggregation, late asynchronous completion, terminal model diagnostics, other non-completed exits, direct failures, Loader-time disposal, and flush-before-exit ordering. The keyless assembled snapshots drive `dsh --profile headless` through a replayed tool round trip, record a `user/message` with `source.kind: 'user'`, and expose a terminal model failure on stderr. Built-bin acceptance reaches a mock provider through the published entry and requires final text on stdout, exit 0, and empty stderr. Config-dump acceptance excludes every Host, Web, and Client package from the shipped headless tree; PTY shutdown coverage requires no observation line and bounded disposal. ## Alternatives considered @@ -39,6 +39,6 @@ Package tests use the real Session store and Agent registry around a scripted Ag ## Consequences -`dsh run` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Successful stderr is empty, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`. +`dsh --profile headless` provides a local Agent task rather than browser observation, Host APIs, or HTTP. Users who need those capabilities choose `dsh web`. Successful stderr is empty, completion follows durable flush, and the persisted Session remains available to later tooling. Its initial user message records `source.kind: 'user'` and therefore carries no ApiProxy `rpcId`. ApiProxy carrier coverage stays in the ApiProxy package. Custom one-shot profiles may include Host or Web bundles explicitly, while the shipped profile and the recognized installation-owned tuple are Web-free. diff --git a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md index 73c1cbe5ac..439f4c21a2 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-headless-direct-core-entry-point.zh.md @@ -20,11 +20,11 @@ Status: implemented `loadProfile` 识别安装过程拥有的精确 headless 元组(`dsh-base`、`dsh-web-app`、`dsh-headless`),将其规范化为随附的 headless 模板,并保留 manifest(元数据清单)的其他所有字段。带额外项、缺少项或顺序不同的组合包列表归用户所有,保持不变。 -本 Agent Note 负责 headless 的传输与完成约定。[`dsh run` 负责一次性 headless 执行](../feature/2026-08-08-dsh-run-headless-command.md)负责命令语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。 +本 Agent Note 负责 headless 的传输与完成约定。[应用持有自己的命令行](2026-08-06-app-owned-command-line.md)负责当前的 `dsh --profile headless` 语法;原 [`dsh run` 决策](../../archived/feature/2026-08-08-dsh-run-headless-command.md)记录已被取代的启动器持有语法,[GUI 分层与 RPC 协议](2026-07-19-gui-layering-and-rpc-protocol.md)负责浏览器网关边界,[Web 配置树启动与传输分层](2026-07-24-web-config-tree-boot-and-transport-layering.md)负责 Web 插件树,[默认模型跟随选择器](../feature/2026-08-07-default-model-follows-the-picker.md)负责共享 Agent 默认值的持久化。 ## 验证 -包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh run`,记录一条带 `source.kind: 'user'` 的 `user/message`,并在 stderr 暴露终止态模型失败。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。 +包测试围绕脚本化 Agent 工厂使用真实的会话存储与 Agent 注册表,固定空闲态到空闲态的聚合、延迟异步完成、终止态模型诊断、其他未完成退出、直接失败、Loader 加载期间的 dispose(资源释放),以及退出前 flush 的顺序。组装后的无密钥快照通过回放的工具往返驱动 `dsh --profile headless`,记录一条带 `source.kind: 'user'` 的 `user/message`,并在 stderr 暴露终止态模型失败。构建后二进制验收通过已发布入口访问 mock 提供方,并要求最终文本出现在 stdout、退出状态为 0 且 stderr 为空。配置转储验收排除随附 headless 树中的所有 Host、Web 与 Client 包;PTY 关闭覆盖要求不出现观察行,并在有界时间内完成 dispose。 ## 考虑过的替代方案 @@ -39,6 +39,6 @@ Status: implemented ## 后果 -`dsh run` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。成功时 stderr 为空,完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。 +`dsh --profile headless` 提供本地 Agent 任务,而不是浏览器观察、Host API 或 HTTP。需要这些能力的用户选择 `dsh web`。成功时 stderr 为空,完成结果在持久化 flush 后推导,持久化会话仍可供后续工具使用。初始用户消息记录 `source.kind: 'user'`,因此不携带 ApiProxy `rpcId`。 ApiProxy 载体覆盖保留在 ApiProxy 包中。自定义一次性 profile 可以显式包含 Host 或 Web 组合包;随附 profile 与可识别的安装过程所属元组均不含 Web。 diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml index 22e5090312..22296f97a9 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.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-08-09-layered-skill-registry.md -2026-08-09-layered-skill-registry.md: 3f092cfb4b722e3dd51fa4dc46c620259eaffa39 -2026-08-09-layered-skill-registry.zh.md: 38b17329c8d46ee9bbd0863f3fae7cf6be39aa75 +2026-08-09-layered-skill-registry.md: 73897c3cb7e0055ff59221b7ea47c5d6ced06991 +2026-08-09-layered-skill-registry.zh.md: 655780d4ef154434d6debf478134d3293d6c564f diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md index 3f092cfb4b..73897c3cb7 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.md @@ -24,7 +24,7 @@ The composition moves with it: the web-app bundle re-enables the base `skill` re **A deployment-level skill reaches every preset-composed session that mounts `tool-skill`.** The repository-plugin e2e's skill root and assertions are restored; the shipped-Web e2e proves the badge row (the same host-registration shape) merges into a standard-preset agent's catalog while the host view stays global-only. -**Layer visibility and consumption stay separate choices.** A core-web agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`. +**Layer visibility and consumption stay separate choices.** A `minimal` agent can read the global layer in principle, but composes no `skill` tool — whether an agent has skills at all remains the preset's decision, made by mounting or omitting `tool-skill`. **Provider options are still the borrowed caller object.** `SkillViewOptions` extends `SkillLookupOptions`; the registry consumes `scope` and providers read only their own contract from the same readonly object, preserving the existing borrow-identity guarantee. diff --git a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md index 38b17329c8..655780d4ef 100644 --- a/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md +++ b/.agents/notes/implemented/architecture/2026-08-09-layered-skill-registry.zh.md @@ -24,7 +24,7 @@ agent-preset stack 曾把整个 skill 能力——注册表、本地提供方和 **部署级 skill 会到达每个挂载 `tool-skill` 的 preset 会话。**repository-plugin e2e 的 skill 根目录与断言已恢复;shipped-Web e2e 证明 badge 行(同一种宿主注册形态)汇入 standard preset agent 的目录,而宿主视图保持仅全局。 -**层可见性与消费仍是两个独立选择。**core-web agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。 +**层可见性与消费仍是两个独立选择。** `minimal` agent 原则上可读全局层,但不组合 `skill` 工具——agent 是否拥有 skill 依旧由 preset 通过挂载或省略 `tool-skill` 决定。 **提供方选项仍是借用的调用方对象。**`SkillViewOptions` 扩展 `SkillLookupOptions`;注册表消费 `scope`,提供方只从同一个只读对象中读取自己的契约,保持既有的借用恒等保证。 diff --git a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml similarity index 54% rename from .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.i18n.yaml index 43e4b3d605..da6e264f1b 100644 --- a/.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.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 .agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md -2026-07-28-experimental-plugin-package-group.md: 1ebae5dbb16d4c966f94ffde69fb0cb9bc163d80 -2026-07-28-experimental-plugin-package-group.zh.md: 2d09451c5069a775906e5bc8748c334c29008164 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md +2026-08-10-host-plane-ownership-after-presets.md: 5b0a340e875005182a0e6cd0f880b34b14258fb2 +2026-08-10-host-plane-ownership-after-presets.zh.md: 4b1e04f924e656b0f6ad4d070a3b77ce0189c608 diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md new file mode 100644 index 0000000000..5b0a340e87 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md @@ -0,0 +1,45 @@ +# Agent Note: What stays host-plane once presets own the agent plane + +Status: implemented + +English | [中文](2026-08-10-host-plane-ownership-after-presets.zh.md) + +## Problem + +[Per-session agent presets](2026-08-03-per-session-agent-presets.md) moved every model-facing row onto the agent plane, and each later fix has been one reader that assumed the world before the move. `tasks` came back to the host because a preset row outside its realm resolved it; `goals` never left for the same reason; a child agent's `toolFilter` was repaired once every model-facing tool became an ancestor contribution rather than a global one ([child agents join their parent's preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md)). + +Two more readers were still on the wrong side of that line. + +`dsh-token-meter` was disabled on the host and mounted inside each preset's `compaction` realm. It takes no configuration, keys every fold by `Session`, and registers no tool or prompt section — but it owns the `tokenUsage`, `contextPressure`, and `contextBreakdown` projection units, and `sessionProjections` is a process-wide table with no scope layering. A unit registered from inside one preset therefore answers for every session: whether a `minimal` session showed a context meter depended on whether some *other* session had mounted `standard` since boot, and a process that only ever ran `minimal` showed none at all. + +Nothing named an agent that joined no preset. The join is a scope-parent link; without it the `tools`, `system-prompt`, and `skill` views resolve the empty global layer and the model receives nothing — no error, no empty catalog, just an agent that cannot act. That is how delegated subagents ran for as long as presets existed, and the same hole is open at every entry point that predates them. + +## Decision + +**The meter is host-plane.** `dsh-token-meter` returns to the host composition and leaves the presets' `isolate` map, so `compact-basic` and `tool-result-prune` resolve the one host instance from inside their realm. The presets keep the realm and the backend — what a preset chooses is whether its agent compacts, not whether its tokens are counted. This is the criterion `tasks` and `goals` are already read by, applied to a Service whose *projection* reach is what made preset ownership wrong: a unit whose empty value is indistinguishable from a real one cannot be per-composition while the table it registers into is per-process. + +**An unjoined agent is named twice, at two different points.** `AgentPresets` logs one warning per agent published with a scope chain of length one while a roster is configured. The invariant companion fails instead — and at `system-prompt/assemble`, not at publication, because an unjoined agent is legal until it addresses a model: `recompose` binds exactly such an agent as its first link, and prompt assembly is the only caller that supplies an agent scope, so a host assembly and a standing mount are both correctly out of range. + +Three limits stay open and are recorded where they bite rather than fixed here: projection key presence is not a per-session capability signal ([`dsh-session-projection`](../../../../packages/session/session-projection/README.md)); a superseded standing generation is never reclaimed, which the settings-page authoring flow turns into a per-save cost ([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.md)); and a temporary plugin mounted through `cordis_mount` belongs to the composition rather than the session that mounted it ([`dsh-tool-cordis`](../../../../packages/self-modification/tool-cordis/README.md)). + +## Testing + +`apps/cli/tests/web-agent-presets.e2e.ts` reads `ctx.get('tokenMeter')` on the booted Web composition before any preset in the file mounts — a preset-side meter sits behind an `isolate` realm and is invisible to `ctx.get`, so the read is an ownership assertion rather than a mount-order coincidence — then asserts a `minimal` session's snapshot carries all three units. + +`packages/preset/agent-presets/tests/mount.spec.ts` asserts the warning fires exactly once for a bare agent and not at all for a joined one. `tests/invariant.spec.ts` carries the negative control: an unjoined agent's assembly rejects, while a joined agent's assembly and a scopeless host assembly both pass. + +## Alternatives considered + +**Keep the meter in the preset and scope-layer the projection registry.** The precise fix, and much larger: `snapshot`, `checkpoint`, and the eager drive would each need a session→scope resolution that a cold read does not have without the api-proxy's `presenterScopeFor`. Rejected as disproportionate to one Service with no per-preset state at all; the general rule is documented on the registry instead. + +**Veto publication for an unjoined agent.** Loud beats silent, and the registry supports it — a synchronous `agent/created` listener that throws rolls the creation back. Rejected because composing an agent outside the roster is legal: `recompose` documents the bare agent it then binds, and the ACP bridge, the SDK server, and the headless bundle all create one today. A veto would convert a capability gap into an outage. + +**Check the join at `agent/created` in the companion too.** Rejected: publication cannot distinguish a missed join from an agent that will be bound later, so the check would reject a documented path. Prompt assembly can distinguish them. + +**Move `plan-mode` and `tool-todo` off the agent plane for the same projection reason.** Rejected: both are genuinely per-preset capabilities, and their units compute an empty value for a session that never uses them, which clients already read by value (`plan.active`, an empty list). Only a unit whose empty value is indistinguishable from a real one — the meter — forces host ownership. + +## Consequences + +The context meter becomes a per-session fact instead of a function of mount history. A preset can no longer opt out of token accounting; no shipped preset did, and `minimal` now says it drops auto-compaction rather than the accounting. + +The warning is advisory, so a deployment that adds a roster to the ACP or SDK-server entry points still starts agents with no tools — it just says so once per agent instead of silently. The invariant reaches only compositions that load `dsh-invariants`, which fences package tests and development hosts, not a shipped one. diff --git a/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md new file mode 100644 index 0000000000..4b1e04f924 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.zh.md @@ -0,0 +1,45 @@ +# Agent Note: What stays host-plane once presets own the agent plane + +Status: implemented + +[English](2026-08-10-host-plane-ownership-after-presets.md) | 中文 + +## Problem + +[逐会话 agent preset](2026-08-03-per-session-agent-presets.md) 把每一个面向模型的行搬上了 agent 平面,此后的每一处修复都是一个仍按搬迁之前的世界写成的读取点。`tasks` 因为 realm 之外的 preset 行要解析它而搬回宿主;`goals` 因为同样的理由从未离开;而当所有面向模型的工具都变成祖先贡献之后,子 agent 的 `toolFilter` 也已被修好([子 agent 加入父方 preset](../bug-fix/2026-08-10-child-agents-join-their-parent-preset.md))。 + +还有两个读取点仍站在这条线的错误一侧。 + +`dsh-token-meter` 在宿主侧被禁用,改挂进每个 preset 的 `compaction` realm。它不接受任何配置,每次折叠都以 `Session` 建键,也不注册工具或提示段——但它拥有 `tokenUsage`、`contextPressure` 与 `contextBreakdown` 三个投影单元,而 `sessionProjections` 是一张进程级、没有作用域分层的表。因此从某个 preset 内部注册的单元会替所有会话作答:一个 `minimal` 会话是否显示 context meter,取决于本次启动以来有没有**别的**会话挂过 `standard`;而只跑过 `minimal` 的进程根本不显示。 + +没有加入任何 preset 的 agent 也无人指出。加入是一条 scope 父链链接;缺了它,`tools`、`system-prompt` 与 `skill` 的视图都解析到空的全局层,模型什么也收不到——不报错,也没有空目录可看,只是一个无法行动的 agent。被委派的子 agent 在 preset 存在的整段时间里都是这样运行的,而同一个洞在每一个早于 preset 的入口点上都开着。 + +## Decision + +**meter 属于宿主平面。** `dsh-token-meter` 回到宿主组装,并离开各 preset 的 `isolate` 映射,于是 `compact-basic` 与 `tool-result-prune` 在自己的 realm 内部解析到那一份宿主实例。preset 保留 realm 与压缩后端——preset 选择的是它的 agent 是否压缩,而不是它的 token 是否被计。这正是 `tasks` 与 `goals` 已经采用的判据,只是这次适用于一个因**投影**触达面而不该归 preset 所有的 Service:当一个单元的空值与真实值无法区分时,只要它注册进的那张表是进程级的,它就不能是逐组装的。 + +**未加入的 agent 在两个不同的点上被指出两次。** 在配置了名单的前提下,`AgentPresets` 对每个作用域链长度为一就发布的 agent 记录一条警告。invariant 配套则直接失败——并且发生在 `system-prompt/assemble` 而非发布时,因为一个未加入的 agent 在它对模型说话之前都是合法的:`recompose` 绑定的正是这样一个 agent 作为它的首次链接;而提示词组装是唯一会提供 agent 作用域的调用方,因此宿主组装与常驻挂载都正确地落在检查范围之外。 + +有三处限制不在此处修复,而是记录在会咬到它们的地方:投影 key 是否存在不能当作逐会话的能力信号([`dsh-session-projection`](../../../../packages/session/session-projection/README.md));被替代的常驻代际永不回收,而设置页的编写流程把它变成每次保存的代价([`dsh-agent-presets`](../../../../packages/preset/agent-presets/README.md));通过 `cordis_mount` 挂上的临时插件属于组装而非挂载它的会话([`dsh-tool-cordis`](../../../../packages/self-modification/tool-cordis/README.md))。 + +## Testing + +`apps/cli/tests/web-agent-presets.e2e.ts` 在本文件中任何 preset 挂载**之前**,于已启动的 Web 组装上读取 `ctx.get('tokenMeter')`——preset 侧的 meter 会待在 `isolate` realm 里,对 `ctx.get` 不可见,因此这次读取是一次所有权断言而不是挂载顺序的巧合——随后断言一个 `minimal` 会话的快照带齐三个单元。 + +`packages/preset/agent-presets/tests/mount.spec.ts` 断言警告对裸 agent 恰好触发一次、对已加入的 agent 完全不触发。`tests/invariant.spec.ts` 承担负控:未加入 agent 的组装被拒绝,而已加入 agent 的组装与不带作用域的宿主组装都通过。 + +## Alternatives considered + +**把 meter 留在 preset,改为给投影注册表分层。** 这是更精确的修法,代价也大得多:`snapshot`、`checkpoint` 与主动驱动都需要一次「会话 → 作用域」的解析,而冷读在没有 api-proxy 的 `presenterScopeFor` 时并不具备。相对于一个完全没有 per-preset 状态的 Service,这不成比例,因此改为把通则写在注册表上。 + +**对未加入的 agent 否决发布。** 大声胜过静默,注册表也支持这么做——同步的 `agent/created` 监听器抛出会把创建整体回滚。否决的理由是:在名单之外组装 agent 是合法的——`recompose` 写明了它随后绑定的那个裸 agent,而 ACP 桥、SDK server 与 headless bundle 今天都会创建一个。否决会把能力缺口变成一次故障。 + +**让配套也在 `agent/created` 处检查加入情况。** 否决:发布时分不清漏掉的加入与之后才会被绑定的 agent,因此该检查会拒绝一条已写明的路径。提示词组装分得清。 + +**基于同样的投影理由,把 `plan-mode` 与 `tool-todo` 也搬离 agent 平面。** 否决:两者确实是逐 preset 的能力,且对从不使用它们的会话,其单元算出的就是空值,而客户端本来就按值读取(`plan.active`、空列表)。只有空值与真实值无法区分的单元——meter——才被迫归宿主所有。 + +## Consequences + +context meter 成为逐会话的事实,而不再是挂载历史的函数。代价是 preset 不能再选择不做 token 记账;随附的 preset 没有一个这么做,`minimal` 现在也写明它放弃的是自动压缩而非记账。 + +那条警告是建议性的,因此给 ACP 或 SDK server 入口加上名单的部署依然会启动没有工具的 agent——只是每个 agent 会说一次,而不再静默。invariant 只触达装载了 `dsh-invariants` 的组装,因此它把关的是包测试与开发宿主,不是随附宿主。 diff --git a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml similarity index 56% rename from .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml rename to .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.i18n.yaml index 5985f18a06..20b1a4cfd5 100644 --- a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.i18n.yaml +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.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 .agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md -2026-07-30-config-only-repository-plugins.md: 35327a30e03c51311f634e05ade209ab93ae0155 -2026-07-30-config-only-repository-plugins.zh.md: 5755045560da761b59f7c65e99d551f599c2b5b3 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md +2026-08-10-message-feedback-sidecar.md: 780cbaa840fcac7bcfa799468bfd61f5b08715cb +2026-08-10-message-feedback-sidecar.zh.md: 72ecc82717010f65d013418a45c3b38c6084aaf9 diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md new file mode 100644 index 0000000000..780cbaa840 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md @@ -0,0 +1,43 @@ +# Agent Note: Lifecycle-bound message feedback sidecar + +Status: implemented + +English | [中文](2026-08-10-message-feedback-sidecar.zh.md) + +## Problem + +The existing `/feedback` command records an immutable Session-level `feedback/record` event. That event can release a pending telemetry prefix under `FEEDBACK_ONLY`, so it is the wrong authority for an editable positive/negative rating and optional note attached to one assistant message. Message feedback needs independent update and delete semantics without entering the canonical Session log, changing a projection, reaching the model surface, or implicitly consenting to telemetry. + +A sidecar keyed only by `SessionId` can outlive the log lifecycle it describes when an id is recreated with a different header identity. A Session-wide revision also makes unrelated message edits conflict, while plain storage-domain read/put has no cross-process compare-and-swap. Session disposal is only live-store detach, not durable deletion, and the current Session persistence seam exposes no deletion operation that could own a truthful cascade. + +## Decision + +`@deepseek-ai/dsh-message-feedback` owns the `ctx.messageFeedback` service and stores message feedback as one storage-domain sidecar row per Session. The sidecar is neither Session-log content nor a Session projection. It emits no `feedback/record` event and performs no telemetry handoff; the command-feedback and message-feedback contracts remain independent. + +Every usable row is bound to the inspected Session header identity `{createdAt, cwd}`, not merely its `SessionId`. A lifecycle mismatch is treated as absence: `list` returns no items, and `put` may replace the stale row with one bound to the current identity. An id reused with a different header identity therefore cannot inherit stale feedback. A fork receives its own Session identity and no sidecar copy: even when the fork seed contains the same assistant messages, feedback remains attached to the Session in which the human recorded it. + +`put` accepts a target only when `SessionPersistence.inspect()` observes a non-empty, append-origin `assistant/message` with that `MessageId`. Replacement-origin messages, empty usage-only assistant records, and non-assistant targets are rejected. Inspection is the cold-safe authority: it neither publishes or resumes an Agent nor commits cold-log repair merely to validate feedback. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session remains an infrastructure failure. A request in the narrow live-detach-to-header-materialization interval can therefore return `session-not-found`, and the caller retries after retirement materialization. + +Before `put` commits a sidecar row, it puts the target log behind a durability barrier. A matching live Session passes through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are checked again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar write, so a committed feedback item never precedes the durable assistant message it references. + +Each message item carries its own opaque version plus Host-assigned `createdAt` and `updatedAt` timestamps. `put` compares the caller's `ifVersion` only with the addressed item, so editing one message does not invalidate another. The comparison is strict even when the desired value already matches, preventing a stale request from crossing an ABA value cycle; a conflict returns the authoritative current item so callers can reconcile without a second read. A matching-version no-op preserves the version and timestamps, while a material update preserves `createdAt`, replaces the version, and keeps `updatedAt` from moving backward. An already-absent delete is likewise successful. Versions are tokens for equality, not counters callers may order or synthesize. + +A per-Session mutation queue encloses lifecycle inspection, sidecar read, conflict evaluation, and whole-row write. This makes one service instance's mutations serial and preserves the per-message compare-and-swap contract inside one Host process. Plugin disposal closes admission, drains accepted queue work, and then closes the storage domain. The underlying storage-domain API provides no cross-process conditional write, so the implementation claims no cross-process linearizability or lost-update protection. + +`maxNoteBytes` is a required deployment choice and bounds the UTF-8 byte length of an optional note; the Web Host bundle sets it explicitly to `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` contract directly through `GatewayService` and `@Remote`. Client Remote aggregate mounting and UI remain separately owned and deferred; their later adapter stays a thin consumer of this Host contract. + +The service performs no fake deletion cascade. `session/disposed` and `host/session-removed` describe detach from live ownership, not durable Session deletion, and Session persistence currently has no delete surface. Sidecar rows can therefore remain after out-of-band log removal; a different `{createdAt, cwd}` prevents such an orphan from becoming feedback for a later Session that reuses the id. + +## Alternatives considered + +**Append edits to the Session log and derive a projection.** Rejected because editable UI metadata would become canonical conversation-adjacent history, forks would replay and inherit it, deletion would require tombstones, and reusing `feedback/record` would silently couple a message rating to telemetry consent. + +**Key feedback globally by `MessageId`, copy it on fork, or use one Session revision.** Rejected because message ids are meaningful only within a Session lifecycle, forked conversations need independent human judgments, and unrelated message mutations must not create false conflicts. + +**Extend `KvTable` with cross-process compare-and-swap in this change.** Rejected because the shipped storage-domain backends expose no common conditional-write primitive. A process-local queue matches the supported one-Host topology; a real multi-process guarantee requires a backend-level atomic contract and is separate work. + +**Delete feedback on Session disposal.** Rejected because disposal includes ordinary detach and rollback paths. Treating it as durable deletion would lose feedback while the Session log still exists; cleanup waits for a real Session deletion authority. + +## Consequences + +Message feedback is locally durable and independently editable without changing model-visible history or telemetry behavior. Concurrent callers in one Host receive per-message conflict detection and retry-safe outcomes, while deployments with multiple writers to the same storage root remain unsupported. A differing header identity treats a stale row as absent but does not reclaim it; a cloned log that retains the same `{createdAt, cwd}` is indistinguishable by this contract. The Host Remote contract is available now; client assembly and UI can remain thin consumers rather than taking ownership of persistence or concurrency semantics. diff --git a/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md new file mode 100644 index 0000000000..72ecc82717 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 绑定生命周期的消息反馈伴随记录 + +Status: implemented + +[English](2026-08-10-message-feedback-sidecar.md) | 中文 + +## 问题 + +现有 `/feedback` 命令记录不可变的 Session 级 `feedback/record` 事件。在 `FEEDBACK_ONLY` 下,该事件可以释放待处理的遥测前缀,因此它不适合作为挂在单条 assistant 消息上的可编辑好评/差评与可选备注的权威来源。消息反馈需要独立的更新与删除语义,且不得进入权威 Session 日志、改变投影、到达模型接口,或隐式表示遥测同意。 + +只按 `SessionId` 建索引的伴随记录可能在该 id 以不同 header 身份重建后,继续存活于其所描述的日志生命周期之外。Session 级 revision 还会让无关消息的编辑彼此冲突,而普通 storage-domain 读/写不提供跨进程 compare-and-swap。Session disposal 只是从 live store 脱离,并非持久删除;当前 Session 持久化 seam 也没有可拥有真实级联的删除操作。 + +## 决策 + +`@deepseek-ai/dsh-message-feedback` 拥有 `ctx.messageFeedback` 服务,并把消息反馈存为每个 Session 一条 storage-domain 伴随记录(sidecar)。该伴随记录既不是 Session 日志内容,也不是 Session 投影。它不发出 `feedback/record` 事件,也不执行遥测交接;command-feedback 与 message-feedback 契约保持独立。 + +每条可用记录都绑定到经检查的 Session header 身份 `{createdAt, cwd}`,而不只是其 `SessionId`。生命周期不匹配按不存在处理:`list` 返回空条目,`put` 可以用绑定当前身份的新记录替换陈旧行。因此,以不同 header 身份复用的 id 不会继承陈旧反馈。fork 拥有自己的 Session 身份,且不复制伴随记录:即使 fork 种子包含相同的 assistant 消息,反馈仍只属于人类记录它的那个 Session。 + +`put` 只接受由 `SessionPersistence.inspect()` 观测到的非空、append-origin `assistant/message`,且其 `MessageId` 必须与目标相同。replacement-origin 消息、仅承载 usage 的空 assistant 记录以及非 assistant 目标都会被拒绝。检查使用 cold-safe 权威路径:它不会仅为验证反馈而发布或恢复 Agent,也不会提交 cold 日志修复。cold 路径由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,仍按基础设施故障处理。因此,请求若恰落在 live detach 到 header materialization 的极短窗口,可能返回 `session-not-found`,调用方在 retirement materialization 后重试。 + +`put` 提交伴随记录前,会先让目标日志通过 durability barrier。身份匹配的 live Session 经过权威 `ctx.sessions.flush` checkpoint,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录写入,因此已提交反馈绝不会先于它引用的持久 assistant 消息。 + +每个消息条目都携带自己的 opaque version,以及 Host 分配的 `createdAt` 和 `updatedAt` 时间戳。`put` 只把调用方的 `ifVersion` 与目标条目比较,因此编辑一条消息不会使另一条消息失效。即使目标值已经相同,比较仍然严格执行,从而防止陈旧请求穿过 ABA 值循环;冲突会返回权威当前条目,调用方无需二次读取即可协调。携带匹配 version 的无变化请求会保留 version 与时间戳;实质更新保留 `createdAt`、替换 version,并保证 `updatedAt` 不倒退。删除已经不存在的条目也同样成功。version 是只能做相等比较的 token,不是调用方可以排序或自行合成的计数器。 + +按 Session 划分的变更队列覆盖生命周期检查、伴随记录读取、冲突判断与整行写入。这使同一个服务实例的变更串行化,并在单个 Host 进程内保持逐消息 compare-and-swap 契约。Plugin disposal 会关闭接纳、排空已进入队列的工作,然后关闭 storage domain。底层 storage-domain API 不提供跨进程条件写,因此实现不承诺跨进程线性一致性或防止丢失更新。 + +`maxNoteBytes` 是必填的部署选择,用于限制可选备注的 UTF-8 字节长度;Web Host bundle 将其显式设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 直接发布 Host `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete` 契约。客户端 Remote 聚合挂载与 UI 由各自边界负责并保持延后;后续适配层只是该 Host 契约的薄消费者。 + +服务不伪造删除级联。`session/disposed` 与 `host/session-removed` 表示脱离 live ownership,而非持久删除,Session persistence 当前也没有删除接口。因此在带外移除日志后,伴随记录可能继续存在;不同的 `{createdAt, cwd}` 可阻止此类孤儿记录变成后来复用该 id 的 Session 反馈。 + +## 考虑过的替代方案 + +**把编辑追加到 Session 日志并派生投影。** 不予采纳,因为可编辑 UI 元数据会变成权威且邻近对话的历史,fork 会回放并继承它,删除需要 tombstone,而复用 `feedback/record` 会把消息评分与遥测同意静默耦合。 + +**按全局 `MessageId` 建索引、在 fork 时复制,或使用一个 Session revision。** 不予采纳,因为消息 id 仅在某个 Session 生命周期内有意义,fork 后的对话需要独立的人类判断,而且无关消息的变更不应制造虚假冲突。 + +**在本次变更中为 `KvTable` 扩展跨进程 compare-and-swap。** 不予采纳,因为出厂 storage-domain 后端没有共同的条件写原语。进程内队列符合受支持的单 Host 拓扑;真实的多进程保证需要后端级原子契约,属于独立工作。 + +**在 Session disposal 时删除反馈。** 不予采纳,因为 disposal 包含普通 detach 与 rollback 路径。把它当成持久删除会在 Session 日志仍存在时丢失反馈;清理必须等待真正的 Session 删除权威。 + +## 后果 + +消息反馈在本地持久化并可独立编辑,且不改变模型可见历史或遥测行为。同一 Host 中的并发调用方获得逐消息冲突检测与可安全重试的结果;多个写入者共享同一存储根目录的部署仍不受支持。不同的 header 身份会让陈旧记录被视为不存在,但不会将其回收;本契约无法区分保留相同 `{createdAt, cwd}` 的克隆日志。Host Remote 契约现在可用;客户端组装与 UI 可以保持为薄消费者,而不接管持久化或并发语义。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.i18n.yaml new file mode 100644 index 0000000000..b696b46749 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.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-08-10-product-subagent-providers-in-shared-host.md +2026-08-10-product-subagent-providers-in-shared-host.md: 33b6eb6cf7a6c19e9ea71cdb7dc8881e8052ef24 +2026-08-10-product-subagent-providers-in-shared-host.zh.md: fd78c7a3fee4e4ee30d27d87c752e1a23576fd85 diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md new file mode 100644 index 0000000000..33b6eb6cf7 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.md @@ -0,0 +1,41 @@ +# Agent Note: Product subagent providers live in the shared profile host + +Status: implemented + +English | [中文](2026-08-10-product-subagent-providers-in-shared-host.zh.md) + +## Problem + +The [Codex and Claude Code provider contracts](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md) were first shipped as independently installable packages that a deployment loaded beside the common subagent tool. Agent Presets later became the ordinary owner of one agent's model-visible tools, but a preset cannot safely own these product providers: `ctx.subagents` is a process registry, provider names are unique, and host consumers resolve the same registry across sessions. Requiring a person to edit both a Profile and a Preset would also make a generic preset row incomplete by itself. + +The placement decision must preserve two independent facts. Loading a provider must not start or authenticate a product, while enabling a tool must remain per preset so two sessions can expose different products. A global product switch, a provider instance per agent, or pre-enumerated combination presets would each create a second owner for one of those facts. + +## Decision + +Every shipped Profile loads the fixed `codex` and `claude-code` providers once through the base bundle's host plane. Loading either plugin only registers a dormant backend; the corresponding Codex or Claude process starts on the first actual delegation call. Agent Presets independently contribute ordinary `dsh-tool-subagent` rows for `subagent_codex` and `subagent_claude_code`, so a preset can expose neither tool, either one, or both without changing the provider registry. + +This decision supersedes only the opt-in composition placement recorded by the provider-contract note. That note continues to own each product protocol, result mapping, cancellation, process-tree lifecycle, and evidence tiers. The [Agent Preset architecture](2026-08-03-per-session-agent-presets.md) continues to own the Host/Agent split, preset authoring, and the rule that edits affect only newly composed sessions. + +The providers use products already selected by the host environment. Codex starts `codex` from `PATH`; Claude Code resolves `claude` through the shared subprocess execution world and passes the exact path to the official SDK. Profile loading does not install a product, create product state, probe a version, test authentication, or add product-specific settings. Missing commands and product failures remain local to the attempted delegation. + +The current base dependency closure still includes the Claude Agent SDK's optional platform CLI payload even though production resolves the host `claude`. Removing that unused payload belongs to the separate product installation-closure follow-up; this placement decision neither installs it dynamically nor treats it as the production executable. + +## Verification + +The base Loader test proves both provider names register exactly once and no product process starts during Profile boot. Real Agent Preset composition covers none, Codex-only, Claude-only, and both tool sets, including generation isolation after an authored preset changes. Keyless ACP snapshots pin the model-visible tool schemas for one and both products, while provider tests separately prove native executable resolution, failure, cancellation, and process-tree quiescence. + +## Alternatives considered + +**Keep product providers opt-in at the Profile layer.** This preserves a smaller default dependency closure, but a copied or agent-authored Preset row is not usable unless the person also discovers and edits a second composition layer. It leaves the general Preset entry incomplete for these otherwise ordinary tools. + +**Store global or per-Profile product enable switches.** A process switch competes with the Preset as owner of model-visible tools and cannot express two sessions using different combinations. Availability and authentication are deployment facts, not another persisted product state. + +**Mount a provider inside every Agent Preset.** Provider names belong to a process registry, so the second session would collide with the first. Host consumers also need the registry independently of any one agent's lifetime. + +**Ship four product-combination presets.** Four identities duplicate complete compositions to represent two independent tool rows. Ordinary rows already express the full matrix without adding roster or maintenance state. + +## Consequences + +A user manages both products through the same Agent Preset authoring path as other plugins, and each new session receives exactly the tools its chosen preset contributes. Every Profile carries two dormant provider registrations, so unused products consume package and module-loading footprint but no product process, login, model call, or product home. + +The Host registry remains the single provider authority and each Preset remains the single model-tool authority. The trade-off is the current Claude SDK optional-payload installation cost, which stays explicitly deferred rather than being hidden behind another enable state or installer lifecycle. diff --git a/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md new file mode 100644 index 0000000000..fd78c7a3fe --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-product-subagent-providers-in-shared-host.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 产品 subagent 提供方位于共享 profile 宿主 + +Status: implemented + +[English](2026-08-10-product-subagent-providers-in-shared-host.md) | 中文 + +## 问题 + +[Codex 与 Claude Code 提供方约定](../feature/2026-08-04-claude-code-and-codex-subagent-backends.md)最初以可独立安装的包交付,由部署环境在通用 subagent 工具旁加载。Agent Preset 后来成为单个 agent(智能体)的模型可见工具的常规责任方,但 preset 不能安全地拥有这些产品提供方:`ctx.subagents` 是进程级注册表,提供方名称唯一,而宿主消费方会跨会话解析同一个注册表。如果要求用户同时编辑 Profile 和 Preset,也会使通用 preset 行本身不完整。 + +归属决策必须同时保留两个彼此独立的事实:加载提供方不得启动产品,也不得对产品执行身份验证;而工具是否启用仍须按 preset 决定,这样两个会话才能暴露不同的产品。全局产品开关、按 agent 创建提供方实例或预先枚举的组合 preset,都会为其中一个事实另设第二责任方。 + +## 决策 + +每个随发行版交付的 Profile 都会通过 base 组合包的宿主平面,把固定的 `codex` 与 `claude-code` 提供方各加载一次。加载任一插件只会注册一个休眠后端;对应的 Codex 或 Claude 进程直到第一次实际委派调用时才启动。Agent Preset 分别通过普通的 `dsh-tool-subagent` 行贡献 `subagent_codex` 与 `subagent_claude_code`,因此一个 preset 可以不暴露任何工具、只暴露其中一个或同时暴露两者,而无需更改提供方注册表。 + +本决策仅取代提供方约定说明所记录的、原先由用户选择启用的组装位置。该说明仍负责每个产品的协议、结果映射、取消、进程树生命周期与证据层级。[Agent Preset 架构](2026-08-03-per-session-agent-presets.md)仍负责宿主与 agent 的划分、preset 创作,以及改动只影响新组装会话的规则。 + +这些提供方使用宿主环境已经选定的产品。Codex 启动 `codex`,该命令从 `PATH` 解析;Claude Code 通过共享的子进程执行世界解析 `claude`,并把确切路径交给官方 SDK。加载 Profile 不会安装产品、创建产品状态、探测版本、测试身份验证,也不会新增产品专属设置。命令缺失和产品故障仍局限于发生问题的那次委派。 + +当前 base 依赖闭包仍包含 Claude Agent SDK 的可选平台 CLI(命令行界面)载荷,尽管生产环境解析的是宿主提供的 `claude`。移除这份未使用载荷属于独立的产品安装闭包后续项;本归属决策既不会动态安装它,也不会将它当作生产可执行文件。 + +## 验证 + +base Loader 测试证明两个提供方名称都恰好注册一次,而且 Profile 启动期间不会启动产品进程。真实 Agent Preset 组装覆盖不暴露任何工具、仅暴露 Codex、仅暴露 Claude 和同时暴露两者这四种工具集合,也覆盖自行创作的 preset 发生改动后的代际隔离。无密钥 ACP(Agent Client Protocol)快照固定单个产品与两个产品同时启用时的模型可见工具 schema,提供方测试则另行证明原生可执行文件解析、失败、取消和进程树完全停稳。 + +## 考虑过的替代方案 + +**将产品提供方保留为 Profile 层的按需启用项。** 这样可缩小默认依赖闭包,但复制或由 agent 创作的 Preset 行无法直接使用,除非用户还发现并编辑第二个组装层。对于这些本来与其他工具无异的工具,通用 Preset 入口仍不完整。 + +**存储全局或按 Profile 配置的产品启用开关。** 进程级开关会与 Preset 争夺模型可见工具的责任归属,也无法表示两个会话使用不同组合。可用性与身份验证属于部署事实,并非另一份需要持久化的产品状态。 + +**在每个 Agent Preset 内挂载一个提供方。** 提供方名称属于进程级注册表,因此第二个会话会与第一个冲突。宿主消费方也需要独立于任何单个 agent 的生命周期使用该注册表。 + +**交付四个产品组合 preset。** 四个身份会复制完整组装,只为表示两条独立的工具行。普通行已经能表达完整矩阵,无需新增名单或维护状态。 + +## 后果 + +用户通过与其他插件相同的 Agent Preset 创作路径管理两个产品,每个新会话只会获得其所选 preset 所贡献的工具。每个 Profile 都携带两个休眠的提供方注册,因此未使用的产品会产生包和模块加载开销,但不会启动产品进程、登录、调用模型或创建产品主目录。 + +宿主注册表仍是提供方的唯一权威,每个 Preset 仍是模型工具的唯一权威。代价是当前 Claude SDK 可选载荷的安装成本继续被明确延期处理,而不会隐藏在另一种启用状态或安装程序生命周期之后。 diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.i18n.yaml new file mode 100644 index 0000000000..ee249b18ea --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.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-08-10-session-log-version-mechanism.md +2026-08-10-session-log-version-mechanism.md: 25eb1230a254219c827b1d2750dba367b113f9f7 +2026-08-10-session-log-version-mechanism.zh.md: c47670f2de77773c17c9595eff442bf7f1e8ec3e diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md new file mode 100644 index 0000000000..25eb1230a2 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md @@ -0,0 +1,30 @@ +# Agent Note: Session log versioning — one integer, an upgrade chain, and a per-event ignorable marker + +Status: implemented + +English | [中文](2026-08-10-session-log-version-mechanism.zh.md) + +## Problem + +Session logs must be upgradable after release, and the runtime that ships first is the floor for every later decision: whatever refusal and degradation behavior is missing from the first released reader can never be added to the copies users already run. Release issue #1901 required at minimum that an old runtime reading a newer session format reports "unsupported" instead of misreading it. The pre-change reader did the opposite on both axes: `assertVersion` rejected any version mismatch with one direction-blind message, and the JSONL decoder passed unknown event types through untouched, so reconstruction silently skipped them — resuming a gutted session with no diagnostic at all. + +## Decision + +**One monotonic integer, no major/minor split.** Whether a version step is auto-upgradable is a property of that step — expressed by whether its upgrader exists — not something a two-level numbering scheme should promise in advance (you rarely know at design time whether the next change will turn out "major"). This matches the SQLite backend's `SCHEMA_VERSION` precedent. + +**The writer decides bumps, not the reader.** A bump is required exactly when an old runtime could no longer handle a new log with full semantic correctness. "Parses without error" is not the bar: silently skipping content that shapes reconstruction is a wrong read. Only structural changes qualify — header shape, event envelope, core event semantics, the surface mechanism (`SurfaceEventType` set, `SurfaceOp` variants). When unsure, bump: a near-identity upgrader is almost free, a missed bump silently corrupts old readers. + +**Read rules by direction.** Equal version: read normally. Newer than the reader: refuse, name the direction ("written by a newer harness — upgrade"), and point at the raw log artifact so the user can still see the text (`SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged). Older than the reader: convert in memory through the chain of n→n+1 upgraders for viewing; persist the converted log only when the session is actually continued (atomic temp-file replace, original kept as backup). A step whose upgrader cannot be written is left empty, which cuts off every version at or below it — those degrade to raw-text viewing. + +**A per-event `ignorable` marker covers vocabulary growth, so ordinary event additions never bump the version.** The event vocabulary is decided by which plugins are mounted, which a single version integer cannot describe. A reader meeting an unrecognized event type refuses to interpret the log unless the event carries `ignorable: true` in its envelope. The default is *required*: forgetting the marker over-refuses a resumable session (an inconvenience), while a default of ignorable would make the same mistake silently resume a gutted one (a safety failure). The architecture makes this sound: model-visible content flows only through the three `surfaceOp`-marked surface event types plus the `request/header`/`request/context` folds, so the dangerous unknowns are exactly the non-surface events that change how the rest of the log is read (`session/end-seed` is the existing example). + +## Consequences + +What shipped in v0 (release 0812): direction-aware refusal with the raw-log path; the unknown-event guard against a generated known-vocabulary list (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog` from every `SessionEventMap` merge and kept fresh by `verify-persistence-catalog`); the `ignorable` envelope field accepted by seed validation, both backends (a dedicated SQLite column, `SCHEMA_VERSION` 15), and the BFF wire schema. The upgrader chain itself is deferred until the first real v0→v1 step exists to test it against; writers do not yet set `ignorable` (no producer needs it), so `Session.append` gains that surface with its first user. Until a registration surface exists, an out-of-repo plugin's events refuse resume under first-party readers — the pre-release stance accepts that, and the refusal is loud rather than silent. The unknown-type guard is read-side only: `appendCore` keeps rejecting retired legacy shapes but does not vocabulary-check new types, because an append-time refusal would stall a live session's durability mid-flight, which costs more than a loud refusal at the log's next load. The JSONL backend additionally refuses a foreign version from the raw header line before validating today's header shape or decoding any event row, so a structurally different future format still reports the upgrade direction instead of "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. + +## Alternatives considered + +- **Major/minor versioning** — the "is it convertible" bit lives on each step's upgrader, and pre-committing it into a number shape invites wrong promises. +- **Default-ignorable unknown events** — inverts the failure mode of a forgotten marker from visible over-refusal into silent corruption. +- **Auto-migrating on view** — rewriting the artifact on open turns a read into a destructive write: a converter bug corrupts logs at browse time, and a same-directory older runtime loses access because a newer one merely looked. +- **Per-plugin runtime registration of known event types** — would make the known set composition-dependent, so a leaner same-version composition would refuse logs a fuller one wrote. The generated repo-wide list keeps same-version reads uniform; out-of-repo plugin events are outside it by construction, and a registration surface for them is deferred until such a consumer exists. diff --git a/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md new file mode 100644 index 0000000000..c47670f2de --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.zh.md @@ -0,0 +1,30 @@ +# Agent Note:Session log 版本机制:单调整数、升级器链、逐事件可忽略标记 + +Status: implemented + +[English](2026-08-10-session-log-version-mechanism.md) | 中文 + +## 问题 + +Session log 在发布后必须能升级格式,而最先发布的运行时决定了此后一切的下限:第一个发布版的读取器缺少哪种拒绝和降级行为,用户手里已经装上的副本就永远补不上。发布 issue #1901 的最低要求是老运行时读到新 Session 格式时明确报不支持,而不是读错。改动前的读取器在两个方向上都做反了:`assertVersion` 对任何版本不匹配抛出同一条不区分方向的消息;JSONL 解码器把不认识的事件类型原样放行,重建时静默跳过,恢复出一个内容残缺的会话且没有任何诊断。 + +## 决定 + +**一个单调递增的整数,不分大小版本。**某一步能不能自动升级是那一步自己的属性,由它的升级器存在与否表达,不该由两级编号方案提前承诺(设计时很少能预知下一个变更算不算"大")。这与 SQLite 后端 `SCHEMA_VERSION` 的先例一致。 + +**升不升版本由写入方决定,与读取方能力无关。**当且仅当老运行时无法在语义上完全正确地处理新日志时才必须升版本。"解析不报错"不是标准:静默跳过影响重建的内容就是读错。只有结构性变更够得上这条线:header 形状、事件信封、核心事件语义、surface 机制(`SurfaceEventType` 集合、`SurfaceOp` 变体)。拿不准就升:近似恒等的升级器几乎没有成本,漏升一次会让老读取器静默读坏。 + +**读取规则按方向区分。**版本相等:正常读。比读取器新:拒绝,说明方向("由更新的 harness 写入,请升级"),并给出原始日志文件的路径,用户仍能看到文本(`SessionFormatUnsupportedError`,与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏)。比读取器旧:查看时经 n→n+1 升级器链在内存中逐级转换;只有会话真正被继续时才把转换落盘(临时文件原子替换,原文件留备份)。写不出升级器的那一步留空,这会切断该步及更早所有版本的升级路径,它们降级为只能看原文。 + +**逐事件的 `ignorable` 标记吸收词汇表增长,普通的新增事件永远不用升版本。**事件词汇表由挂载了哪些插件决定,单个版本整数描述不了它。读取器遇到不认识的事件类型时拒绝解读日志,除非该事件的信封带 `ignorable: true`。默认为必需:忘写标记的后果是把一个本可恢复的会话拒绝过头(体验问题),而默认可忽略会让同样的疏忽静默恢复出残缺会话(安全事故)。架构保证了这条规则成立:模型可见内容只经三种带 `surfaceOp` 标记的 surface 事件加 `request/header`、`request/context` 折叠进入重建,危险的未知事件恰好是那些不进 surface 但改变日志其余部分解读方式的事件(`session/end-seed` 是现存例子)。 + +## 影响 + +v0(0812 发布)交付的内容:分方向的拒绝并带原始日志路径;基于生成的已知词汇清单(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 从所有 `SessionEventMap` 声明合并生成,`verify-persistence-catalog` 保证新鲜)的未知事件守卫;`ignorable` 信封字段被种子校验、两个后端(SQLite 专用列,`SCHEMA_VERSION` 升到 15)和 BFF 线上 schema 接受。升级器链本身推迟到第一个真实的 v0→v1 变更出现、有真实对象可测时再建;写入侧目前不写 `ignorable`(还没有生产者需要它),`Session.append` 的这一表面随第一个使用者一起落地。在注册表面出现之前,仓库外插件的事件在第一方读取器下无法恢复会话,预发布立场接受这一点,而且拒绝是显式的而非静默的。未知类型守卫只在读取侧生效:`appendCore` 继续拒绝已淘汰的 legacy 形状,但不对新类型做词汇检查,因为写入时拒绝会让活跃会话的持久化中途停摆,代价大于下次加载时的显式拒绝。JSONL 后端还会在校验当前 header 形状、解码任何事件行之前,直接从原始 header 行拒绝外来版本,因此结构完全不同的未来格式仍会报告升级方向而不是"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。 + +## 曾考虑的替代方案 + +- **大小两级版本号**:能否转换这一位信息属于每一步的升级器,把它预先固化进编号形状会做出错误承诺。 +- **未知事件默认可忽略**:把忘写标记的后果从可见的过度拒绝反转成静默损坏。 +- **查看时自动迁移落盘**:打开即改写把读操作变成破坏性写操作,转换器的 bug 会在浏览时损坏日志,同目录的旧版本运行时也会因为新版本只是看了一眼就失去访问能力。 +- **插件运行时注册已知事件类型**:会让已知集依赖插件组合,同版本的精简组合会拒绝完整组合写出的日志。生成的全仓库清单保证同版本读取行为一致;仓库外插件的事件按构造就在清单之外,为它们提供注册表面推迟到真有这样的消费者时再做。 diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.i18n.yaml new file mode 100644 index 0000000000..6db3babb64 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.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-08-11-trajectory-conversation-context-assembly.md +2026-08-11-trajectory-conversation-context-assembly.md: 7d0aea2fc09f0f04bd5de923bee15c42a773489a +2026-08-11-trajectory-conversation-context-assembly.zh.md: be3903bc62a8b1cc21bd9ddac450541ad4679ff0 diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md new file mode 100644 index 0000000000..7d0aea2fc0 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.md @@ -0,0 +1,105 @@ +# Agent Note: Trajectory assembly from registered Conversation Contexts + +Status: implemented + +English | [中文](2026-08-11-trajectory-conversation-context-assembly.zh.md) + +## Problem + +Trajectory maintained an independent Session History source and folded the complete loaded Event window into Assistant, Tool, message, Request-header, and Compaction state. Chat already assembled the same Event families through registered Conversation Definitions. The two paths duplicated business correlation and pagination behavior, and a Trajectory structural update copied or rescanned work proportional to the raw Event count even when one business object changed. + +Reusing Chat's final Nodes would not solve the ownership problem. Trajectory needs request lifecycles, running Assistant state, prompt inheritance, Tool schemas, timing records, and a stage-oriented read model that Chat does not consume. Sharing final Node payloads would couple both views to the union of their requirements. + +The migration also had to preserve durable steering classification. A `user/message` does not say whether it opened a Turn or was claimed from the `next-step` inbox, and an older page can supply the missing inbox predecessor or Location after the message has already materialized. + +## Decision + +Trajectory registers target-owned Conversation Definitions and a `trajectory` View Builder against the shared [`ConversationNodeAssembler`](2026-08-09-client-conversation-node-assembly.md). Session owns one contiguous Event window and publishes both Chat and Trajectory snapshots through `Session.views`; it does not run a second Trajectory history source or business fold. + +Each Definition belongs to one target. Chat and Trajectory may recognize the same durable Event family, but they keep separate State and final Node payloads. They share only the Assembler's exact-ID matching, ordered Matches, Location facts, Reader dependencies, publication scheduling, and replace/prepend/append lifecycle. + +The existing [Trajectory inspection ledger](../feature/2026-07-27-trajectory-inspection-ledger.md) remains the view model. The Trajectory Builder converts materialized target Nodes into its established `eventNodes`, Requests, Tool schemas, running calls, and Location map; layout, table virtualization, selection, Overview, and inspector behavior do not become generic Conversation contracts. + +### Business Definitions + +| Business | Context identity | State assembly | Trajectory contribution | +|---|---|---|---| +| `next-step` inbox | splice Event seq | Apply the splice to the nearest preceding inbox Context | State only; no visible Node | +| User, steering, or injected message | message Event seq | Read the preceding inbox State and classify the durable message | Input or context Node | +| Assistant and ordinary Request | `turn:step` | Fold `step/start`, chunks, final message, retry, and `step/end` | Final Assistant, partial Assistant, and Request | +| Root Tool call | root call ID | Fold root call/result and nested Code Dispatch events into one call tree | Final or running Tool tree | +| Compaction | compaction ID | Fold start, summary, end, and replacement checkpoint | Compaction Request | +| Request header | header Event seq | Read the preceding header and retain effective prompt plus the actual change | Prompt and Tool-schema source | +| Session and Turn boundaries | boundary Event seq | Retain closure time and error facts | Interrupted Compaction or failed ordinary Request | + +Every correlating Event must expose the same business ID directly. Code Dispatch uses `rootCallId`, Compaction uses its compaction ID, and ordinary Tool and retry events retain their protocol identities even when a specific Definition correlates by `turn:step`. Legacy records that lack the required correlation ID are ignored by that Definition rather than merged into an `undefined` Context or crashing the Session. + +Assistant chunks update only their `turn:step` Context. Content-bearing chunks request animation-frame publication; usage and finish chunks update State without forcing their own frame. A final message, retry, or boundary publishes immediately. Completed Assistant State retains assembled blocks, timing, usage, and retry facts rather than copying the raw chunk ledger into the target snapshot. + +### Steering from predecessor Contexts + +Trajectory reconstructs steering from durable inbox history, using the same identity rule as the [Chat steering decision](../feature/2026-08-04-web-context-source-and-steer-marks.md) without sharing Chat's final Node. + +Each `agent/inbox/spliced` Event targeting `next-step` starts an invisible Context identified by its Event seq. Its `start()` reads the nearest earlier inbox Context, applies the splice, and stores the pending identities plus the cumulative set of claimed message IDs. A later user-origin `user/message` reads the nearest earlier inbox Context: a claimed ID produces a Steering Node, while every other user-origin message produces an ordinary User Node. + +A Reader miss while older history remains records a window-gap dependency. When prepend supplies the missing predecessor, the Assembler replays the affected inbox chain and message Contexts in forward Event order. Historical page direction therefore cannot permanently misclassify a message. + +The message Event's Location places steering in the owning Step. If the loaded history window lacks enough boundary Events to resolve that Location, layout uses the following Assistant step as the positional fallback. A running Request marker follows leading steering input in the same Step, so the marker denotes the model Request caused by that input rather than appearing before it. + +### Window paths and complexity + +Let `E` be the loaded raw Event count, `P` one newly prepended page, `D` the number of Trajectory Definitions, `C` the number of materialized Trajectory Context contributions, and `Mᵣ` the total Matches held by Contexts invalidated by a prepend. `D` is a small registered set; streaming chunks aggregate into one Assistant Context, so `C` is normally much smaller than `E`. + +| Path | Context work | Target snapshot work | Result | +|---|---|---|---| +| Initial tail or reconnect replace | Match the loaded window in `O(E × D)` and build State in forward Event order | Build and order `C` contributions | A full replace remains proportional to the loaded window | +| Older-page prepend | Match only fresh Events and replay only Contexts whose Match, Location, or Reader answer changed, in `O(P × D + Mᵣ)` | Rebuild the stage snapshot from `C` contributions | Business folding does not restart over all `E` Events | +| Live append | Match in `O(D)`, locate the keyed Context in `O(1)`, and update only that State | Replace a same-anchor contribution in `O(1)` before snapshot assembly | Business correlation is independent of loaded Event history | + +The Builder stores contributions by Context key and keeps a key-to-position index. A content update with the same anchor replaces one contribution in place; a new contribution or anchor change rebuilds and sorts contribution order. Snapshot assembly then walks `C` contributions, indexes Request headers and Tool schemas with Maps, and handles Compaction boundaries and Turn errors with linear cursors or indexes. + +Final Event and Request ordering keeps a publication's current upper bound at `O(C log C)`. The migration removes repeated reverse lookups and the old raw-history refold, but it does not claim end-to-end `O(1)` publication. Chat retains its existing keyed snapshot behavior and complexity; adding the Trajectory target does not make Chat scan Trajectory Contexts or Nodes. + +### Independent presentation hot paths + +The Context migration and the following presentation optimizations solve different costs. These reductions preserve the existing view model and are theoretical from call counts and asymptotic behavior; this decision does not claim benchmark measurements. + +| Hot path | Retained behavior | Expected reduction | +|---|---|---| +| Markdown summaries | Layout retains source Markdown; each stable Table record memoizes its displayed summary by content, while Detail parses only the selected record | A one-record append reparses the changed visible record instead of every Markdown record | +| Search text | `TrajectorySearchIndex` linearly checks stable Record IDs and source signatures, but normalizes Markdown only for changed records and commits updates in three-second batches | Signature comparison remains `O(C)`; expensive normalization follows the changed-record count, and continuous frame updates collapse into one batch per interval | +| Timeline tooltip | Timing text is computed after the delayed tooltip opens | A render with no open tooltip performs no per-span label formatting | +| Following Assistant lookup | One reverse pass records the next Assistant for every input position | The former repeated forward lookup falls from worst-case `O(C²)` to `O(C)` | +| Group duration | Fixed decimal grouping replaces `toLocaleString('en-US')` for the invariant English numeric shape | Complexity remains linear in Groups, but the Intl formatter leaves the repeated render path | + +Display memoization and search indexing stay separate. Search must include off-screen records and may lag live changes by the throttle interval; Table rendering must update the visible changed record immediately and must not inherit the index's commit cadence. + +## Alternatives considered + +**Keep the independent Session History fold and optimize it locally.** Rejected: caches could reduce selected hot paths, but Trajectory would still own a second Event window, pagination repair, request inspection fold, and business-correlation implementation beside Chat. + +**Reuse Chat Definitions and branch on a `target` argument in `buildViewNode()`.** Rejected: Trajectory needs different State and intermediate records, not only another React renderer. One Definition would carry both views' payloads and conditionals and would invalidate unrelated target data when either view changed. + +**Create a Trajectory-specific Assembler.** Rejected: exact-ID routing, update-before-start collection, prepend replay, Location repair, Reader dependencies, and publication cadence are not Trajectory-specific. A second engine would recreate the lifecycle duplication this change removes. + +**Add generic Surface, rewind, fanout, or settled lifecycle concepts.** Rejected: the current durable Event stream does not require a generic Surface branch, and Session or Turn boundaries are target business inputs rather than a reason to fan out one Event over every historical Context. Completion remains business State interpreted with Location closure. + +**Replace the Trajectory stages with generic Conversation Nodes.** Rejected: stages organize requests, timing, schemas, and table layout for one view. Making them engine contracts would constrain a future plain Session-log view and return view-specific composition to Client Runtime. + +**Share one Markdown cache between display and search.** Rejected: display is immediate and viewport-bound, while search covers the complete loaded record set and intentionally batches updates. A shared cache would couple correctness and scheduling across unrelated consumers. + +## Verification + +Runtime tests pin target registration, exact-ID append, update-before-start replay, prepend identity, Reader window-gap repair, Location replay, and isolation between Chat and Trajectory snapshots. + +Trajectory Definition and Builder tests pin Assistant streaming and interruption, nested Tool calls and parallel interruption, Compaction and prompt inheritance, Steering classification and Step placement, Request marker order, stable contribution replacement, and prepend expansion. Table, layout, Timeline, and search tests pin deferred Markdown work, throttled index updates, tooltip-time formatting, and stable search results across append and prepend. + +## Consequences + +Trajectory business assembly now scales with the changed page or keyed Context instead of restarting from the complete raw Event window. Target-owned Definitions can evolve independently from Chat while retaining one Session window and one set of lifecycle rules. Steering becomes a first-class Trajectory record at its actual Step position without adding steering-specific state to Session. + +The retained stage-oriented Builder still performs work proportional to materialized Trajectory contributions and may sort on publication. The search index still performs a light linear signature pass when its input layout changes. These costs are explicit target-view work, not hidden full Event refolding. + +Definition authors must provide stable protocol identities. Old Events without a required ID can disappear from the affected Trajectory business view, which is preferable to joining unrelated records or failing history load; producers that require faithful display must log the identity. + +The [Conversation assembly decision](2026-08-09-client-conversation-node-assembly.md) remains the authority for the generic Context, Reader, Location, and publication contracts. The [Trajectory ledger decision](../feature/2026-07-27-trajectory-inspection-ledger.md) remains the authority for table hierarchy, virtualization, inspector, and interaction behavior. This Note owns how Trajectory adapts those two decisions and why the adaptation does not share final Nodes with Chat. diff --git a/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md new file mode 100644 index 0000000000..be3903bc62 --- /dev/null +++ b/.agents/notes/implemented/architecture/2026-08-11-trajectory-conversation-context-assembly.zh.md @@ -0,0 +1,105 @@ +# Agent Note: Trajectory 基于注册式 Conversation Context 组装数据 + +Status: implemented + +[English](2026-08-11-trajectory-conversation-context-assembly.md) | 中文 + +## 问题 + +Trajectory 曾维护独立的 Session History 数据源,并把完整的已加载 Event 窗口折叠为 Assistant、Tool、消息、Request header 和 Compaction 状态。Chat 已经通过注册式 Conversation Definition 组装相同的 Event 族。两条链路重复实现业务关联与分页行为;即使只改变一个业务对象,Trajectory 的结构更新仍会复制或重新扫描与原始 Event 数量成正比的数据。 + +复用 Chat 的最终 Node 无法解决职责问题。Trajectory 需要请求生命周期、运行中 Assistant 状态、提示词继承、Tool schema、计时记录和 stage-oriented read model,而 Chat 不消费这些数据。共享最终 Node payload 会让两个视图都依赖双方需求的并集。 + +本次迁移还必须保留持久 steering(中途引导)分类。`user/message` 本身不说明它是开启了一个 Turn,还是从 `next-step` inbox 被领取;更早页面还可能在消息已经物化后,才补齐缺失的 inbox 前驱或 Location。 + +## 决策 + +Trajectory 针对共享的 [`ConversationNodeAssembler`](2026-08-09-client-conversation-node-assembly.md) 注册 target 自有的 Conversation Definition 和 `trajectory` View Builder。Session 只维护一份连续 Event 窗口,并通过 `Session.views` 发布 Chat 与 Trajectory 快照;它不再运行第二套 Trajectory history source 或业务 fold。 + +每个 Definition 只属于一个 target。Chat 与 Trajectory 可以识别同一持久 Event 族,但分别维护自己的 State 和最终 Node payload。它们只共享 Assembler 的精确 ID 匹配、有序 Match、Location 事实、Reader 依赖、发布调度,以及 replace/prepend/append 生命周期。 + +既有的 [Trajectory 检查记录表](../feature/2026-07-27-trajectory-inspection-ledger.md)继续作为视图模型。Trajectory Builder 把已物化的 target Node 转换为原有的 `eventNodes`、Requests、Tool schema、运行中调用和 Location map;layout、表格虚拟化、选择、Overview 与检查器行为不会成为通用 Conversation 约定。 + +### 业务 Definition + +| 业务 | Context 标识 | State 组装方式 | Trajectory contribution | +|---|---|---|---| +| `next-step` inbox | splice Event seq | 把 splice 应用到最近的前序 inbox Context | 只维护状态,不产生可见 Node | +| 用户、steering 或注入消息 | message Event seq | 读取前序 inbox State,并对持久消息分类 | Input 或 context Node | +| Assistant 与普通 Request | `turn:step` | 折叠 `step/start`、chunk、最终消息、retry 和 `step/end` | 最终 Assistant、partial Assistant 与 Request | +| 根 Tool call | root call ID | 把根 call/result 与嵌套 Code Dispatch Event 折叠为一棵调用树 | 最终或运行中的 Tool tree | +| Compaction | compaction ID | 折叠 start、summary、end 和 replacement checkpoint | Compaction Request | +| Request header | header Event seq | 读取前一个 header,保留生效提示词及真实变化 | Prompt 与 Tool-schema 来源 | +| Session 与 Turn 边界 | boundary Event seq | 保留关闭时间和错误事实 | 被中断的 Compaction 或失败的普通 Request | + +每个关联 Event 都必须直接提供相同的业务 ID。Code Dispatch 使用 `rootCallId`,Compaction 使用 compaction ID;即使某个 Definition 按 `turn:step` 关联,普通 Tool 与 retry Event 仍保留各自的协议标识。缺少必要关联 ID 的旧记录由该 Definition 忽略,不会合入 `undefined` Context,也不会导致 Session 崩溃。 + +Assistant chunk 只更新对应的 `turn:step` Context。带内容的 chunk 请求 animation-frame 发布;usage 与 finish chunk 更新 State,但不单独强制刷新一帧。最终消息、retry 或边界立即发布。已完成 Assistant State 只保留组装后的 block、计时、usage 与 retry 事实,不会把原始 chunk ledger 复制进 target snapshot。 + +### 通过前序 Context 恢复 steering + +Trajectory 从持久 inbox 历史恢复 steering,使用与 [Chat steering 决策](../feature/2026-08-04-web-context-source-and-steer-marks.md)相同的标识规则,但不共享 Chat 的最终 Node。 + +每条目标为 `next-step` 的 `agent/inbox/spliced` Event 都会启动一个以 Event seq 标识的不可见 Context。它的 `start()` 读取最近的前序 inbox Context,应用 splice,并存储待处理标识以及累计的已领取 message ID 集合。后续用户来源的 `user/message` 读取最近的前序 inbox Context:已领取的 ID 生成 Steering Node,其余用户来源消息生成普通 User Node。 + +仍有更早历史时,Reader miss 会记录 window-gap 依赖。prepend 补齐缺失的前驱后,Assembler 按 Event 正序重放受影响的 inbox chain 与 message Context。因此,历史分页方向不会永久错误分类消息。 + +消息 Event 的 Location 会把 steering 放进所属 Step。如果已加载历史窗口缺少足够的边界 Event,无法解析该 Location,layout 就以后续 Assistant step 作为位置回退。同一个 Step 中,运行中 Request 标记排在前置 steering 输入之后,因此该标记表示由这条输入触发的模型 Request,而不会出现在输入前面。 + +### 窗口链路与复杂度 + +记 `E` 为已加载原始 Event 数,`P` 为一次新 prepend 的页面,`D` 为 Trajectory Definition 数,`C` 为已物化的 Trajectory Context contribution 数,`Mᵣ` 为一次 prepend 使其失效的 Context 所持有的 Match 总数。`D` 是较小的注册集合;流式 chunk 会聚合到同一个 Assistant Context,因此通常 `C` 明显小于 `E`。 + +| 链路 | Context 工作量 | Target snapshot 工作量 | 结果 | +|---|---|---|---| +| 初始尾页或重连 replace | 以 `O(E × D)` 匹配已加载窗口,并按 Event 正序构造 State | 构造并排序 `C` 个 contribution | 完整 replace 仍与已加载窗口成正比 | +| 更早页面 prepend | 只匹配新 Event,并只重放 Match、Location 或 Reader 答案发生变化的 Context,成本为 `O(P × D + Mᵣ)` | 从 `C` 个 contribution 重建 stage snapshot | 业务 fold 不会从头重跑全部 `E` 个 Event | +| 实时 append | 以 `O(D)` 匹配,以 `O(1)` 找到 keyed Context,并只更新对应 State | snapshot 组装前,以 `O(1)` 替换 anchor 未变的 contribution | 业务关联成本与已加载 Event 历史无关 | + +Builder 按 Context key 保存 contribution,并维护 key-to-position index。anchor 相同的内容更新会原位替换一个 contribution;新增 contribution 或 anchor 变化才会重建并排序 contribution 顺序。随后,snapshot assembly 遍历 `C` 个 contribution,用 Map 索引 Request header 与 Tool schema,并以线性游标或索引处理 Compaction boundary 与 Turn error。 + +最终 Event 和 Request 排序使单次发布的当前上界保持为 `O(C log C)`。本次迁移移除了重复反向查找和旧的原始历史 refold,但不声称端到端发布达到 `O(1)`。Chat 保持既有 keyed snapshot 行为与复杂度;增加 Trajectory target 不会让 Chat 扫描 Trajectory Context 或 Node。 + +### 独立的表现层热点优化 + +Context 迁移与下列表现层优化解决的是不同成本。这些优化保留既有视图模型;收益来自调用次数和渐进复杂度推算,本决策不声称存在 benchmark 实测结果。 + +| 热点 | 保留的行为 | 预期减少的工作 | +|---|---|---| +| Markdown 摘要 | Layout 只保留源 Markdown;每个稳定 Table record 按内容 memo 展示摘要,Detail 只解析当前选中记录 | 单条 record append 只重解析发生变化的可见记录,而非全部 Markdown record | +| 搜索文本 | `TrajectorySearchIndex` 仍线性核对稳定 Record ID 与来源签名,但只为变化的 record 标准化 Markdown,并以三秒批次提交更新 | 签名比较仍为 `O(C)`;昂贵标准化只随变化 record 数量增长,持续 frame update 每个时间窗合并成一个批次 | +| Timeline tooltip | 延迟 Tooltip 打开后才计算计时文案 | 没有打开 Tooltip 的 render 不执行逐 span label 格式化 | +| 后继 Assistant 查找 | 一次反向遍历为每个输入位置记录后续 Assistant | 原先重复向前查找的最坏复杂度从 `O(C²)` 降为 `O(C)` | +| Group duration | 以固定十进制分组替代固定英文数字形态下的 `toLocaleString('en-US')` | 复杂度仍与 Group 数线性相关,但重复 render 路径不再调用 Intl formatter | + +展示 memo 与搜索索引彼此独立。搜索必须覆盖屏幕外 record,并允许实时变化延迟一个 throttle 周期;Table 必须立即更新发生变化的可见 record,不能继承索引的提交节奏。 + +## 考虑过的替代方案 + +**保留独立 Session History fold,只做局部优化。** 不予采纳:缓存可以降低部分热点,但 Trajectory 仍会在 Chat 之外拥有第二套 Event 窗口、分页修复、request inspection fold 与业务关联实现。 + +**复用 Chat Definition,并在 `buildViewNode()` 中按 `target` 分支。** 不予采纳:Trajectory 需要不同的 State 与中间 record,不只是另一套 React renderer。单一 Definition 会携带两个视图的 payload 与条件,并在任一视图变化时让无关 target 数据失效。 + +**创建 Trajectory 专属 Assembler。** 不予采纳:精确 ID 路由、先 update 后 start 的收集、prepend replay、Location 修复、Reader 依赖与发布节奏都不是 Trajectory 特有行为。第二套引擎会重新制造本次改造要消除的生命周期重复。 + +**增加通用 Surface、rewind、fanout 或 settled 生命周期。** 不予采纳:当前持久 Event stream 不需要通用 Surface branch;Session 或 Turn boundary 是 target 业务输入,不构成把一个 Event fanout 到全部历史 Context 的理由。完成条件仍由业务 State 结合 Location closure 判断。 + +**用通用 Conversation Node 替换 Trajectory stage。** 不予采纳:stage 为单一视图组织 Request、计时、schema 和表格 layout。把它变成引擎约定会限制未来的朴素 Session-log 视图,并把视图专属组合重新放回 Client Runtime。 + +**在展示与搜索之间共享一套 Markdown cache。** 不予采纳:展示要求立即更新且受 viewport 约束,搜索则覆盖全部已加载 record,并有意批量提交更新。共享 cache 会把两个无关消费方的正确性与调度节奏耦合起来。 + +## 验证 + +Runtime 测试固定 target 注册、精确 ID append、先 update 后 start 的 replay、prepend identity、Reader window-gap 修复、Location replay,以及 Chat 与 Trajectory snapshot 隔离。 + +Trajectory Definition 与 Builder 测试固定 Assistant streaming 与 interruption、嵌套 Tool call 和并行 interruption、Compaction 与 prompt 继承、Steering 分类和 Step 位置、Request 标记顺序、稳定 contribution 替换与 prepend 扩展。Table、layout、Timeline 与搜索测试固定延迟 Markdown 工作、节流索引更新、Tooltip 展示时格式化,以及 append/prepend 期间稳定的搜索结果。 + +## 后果 + +Trajectory 业务组装的成本随变化页面或 keyed Context 增长,不再从完整原始 Event 窗口重新开始。target 自有 Definition 可以独立于 Chat 演进,同时继续共享一份 Session 窗口和一套生命周期规则。steering 会在实际所属 Step 位置成为一等 Trajectory record,不需要向 Session 增加 steering 专属状态。 + +保留的 stage-oriented Builder 仍会执行与已物化 Trajectory contribution 数量成正比的工作,并可能在发布时排序。输入 layout 变化时,搜索索引仍会执行一次轻量线性签名检查。这些成本是显式的 target view 工作,不是隐藏的完整 Event refold。 + +Definition 作者必须提供稳定的协议标识。缺少必要 ID 的旧 Event 可能不会出现在受影响的 Trajectory 业务视图中;与合并无关记录或让历史加载失败相比,这是更安全的退化方式。要求完整展示的生产方必须记录该标识。 + +[Conversation assembly 决策](2026-08-09-client-conversation-node-assembly.md)继续作为通用 Context、Reader、Location 与发布约定的真源。[Trajectory ledger 决策](../feature/2026-07-27-trajectory-inspection-ledger.md)继续负责表格层级、虚拟化、检查器和交互行为。本 Note 负责说明 Trajectory 如何适配这两项决策,以及为何该适配不与 Chat 共享最终 Node。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml index 34f0f1457e..e4ff6a9cfa 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.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-08-03-cli-signal-shutdown-escalation.md -2026-08-03-cli-signal-shutdown-escalation.md: 55917400fac2728d13dc2cdd799a7e234b6ed661 -2026-08-03-cli-signal-shutdown-escalation.zh.md: c7897a8d77e8c2ebad43cec4e12170b04c837350 +2026-08-03-cli-signal-shutdown-escalation.md: 173d06482cd8a1fcbb985763cc313e3f9b170bc6 +2026-08-03-cli-signal-shutdown-escalation.zh.md: efa52524199906cf636cb2b55cb4249857dc1348 diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md index 55917400fa..173d06482c 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.md @@ -6,7 +6,7 @@ English | [中文](2026-08-03-cli-signal-shutdown-escalation.zh.md) ## Problem -The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh run`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. +The default telemetry mount added SIGINT/SIGTERM handlers to `dsh web` and the headless command (now `dsh --profile headless`) so process exit could drain the Cordis tree instead of dropping queued telemetry. Each handler used a one-way boolean latch and exited only after `ctx.fiber.dispose()` settled. Headless normal completion also awaited that disposal without a bound. A user then reproduced the headless command hanging immediately after the observation URL and ignoring repeated `Ctrl+C`; `DSH_TELEMETRY_DISABLED=1` removed the hang, while a standalone Node handler in the same Linux sandbox received SIGINT. This isolated the pending disposer to telemetry rather than terminal signal forwarding. OTel's `BatchLogRecordProcessor.shutdown()` awaits `exporter.forceFlush()` before the `exportTimeoutMillis`-bounded completion promise, and the OTLP exporter's `forceFlush()` waits directly on its in-flight HTTP Promise. A proxy/sandbox connection that never obtains a socket can therefore leave provider shutdown pending despite both configured SDK timeouts. diff --git a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md index c7897a8d77..efa5252419 100644 --- a/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md +++ b/.agents/notes/implemented/bug-fix/2026-08-03-cli-signal-shutdown-escalation.zh.md @@ -6,7 +6,7 @@ Status: implemented ## 问题 -默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh run`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 +默认挂载遥测后,`dsh web` 与 headless 命令(现为 `dsh --profile headless`)新增了 SIGINT/SIGTERM 处理器,使进程退出时可以排空 Cordis 插件树,而不是丢弃排队中的遥测数据。每个处理器都使用单向布尔闩锁(latch),并且只有在 `ctx.fiber.dispose()` 结算后才退出。headless 正常完成时同样会无界等待整棵树执行 dispose(资源释放)。 随后有用户复现,headless 命令在打印观察 URL 后立即卡死,重复按 `Ctrl+C` 也没有反应;设置 `DSH_TELEMETRY_DISABLED=1` 后不再卡死,而同一 Linux 沙箱中的独立 Node 信号处理器能够收到 SIGINT。这将待结算的 disposer 定位到遥测,而非终端信号转发。OTel 的 `BatchLogRecordProcessor.shutdown()` 会先等待 `exporter.forceFlush()`,再进入受 `exportTimeoutMillis` 限制的完成 promise;OTLP 导出器的 `forceFlush()` 则直接等待正在进行的 HTTP Promise。因此,代理/沙箱连接始终无法取得 socket 时,即使已经配置两项 SDK 超时,也会让提供方关闭一直待结算。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml deleted file mode 100644 index 86e63238fa..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.i18n.yaml +++ /dev/null @@ -1,6 +0,0 @@ -# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each -# side as of the last confirmed-consistent state. Both languages carry equal authority; -# after editing either side, bring the other along and re-record with: -# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md -2026-08-08-npm-backed-git-repository-plugin-preparation.md: 958b932f82f4da3cf63aa911260411855e514409 -2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md: d2256e0eae303c371371b9b5ba1967105aa61834 diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md deleted file mode 100644 index 958b932f82..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md +++ /dev/null @@ -1,48 +0,0 @@ -# Agent Note: npm-backed preparation makes GitHub repository Plugins self-contained - -Status: implemented - -English | [中文](2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md) - -## Problem - -The repository Plugin authoring contract requires `scripts.prepack` to invoke `dsh-plugin-prepare`. Supplying that executable from the running DSH installation made a source package appear valid even when its own manifest could not obtain the helper. It therefore did not prove the behavior users need after `@deepseek-ai/dsh-repository-plugin` is published: an ordinary Git-hosted npm package must be installable and preparable from only its declared dependencies. - -A selectable `.dsh-plugin` inside a pnpm workspace has a second isolation requirement. pnpm prepares a Git-hosted package by running the repository's preferred package manager before packing the selected subdirectory. A nested `pnpm install` can join the containing workspace; when the root lockfile does not list `.dsh-plugin` as an importer, pnpm can report success without installing dependencies declared only by that package. Its TypeScript build or prepare command then fails, or a pre-generated artifact hides the missing dependency. - -The checked-in headless fixture mounts an already prepared wrapper. It proves runtime composition, not GitHub acquisition, npm resolution, or package-owned preparation. - -## Decision - -The `.dsh-plugin` package declares `@deepseek-ai/dsh-repository-plugin` as an ordinary development dependency and invokes its published `dsh-plugin-prepare` executable from `scripts.prepack`. The package may declare any other build and runtime dependencies and run arbitrary compilation before the helper. The repository Plugin package marks its Cordis and DSH peers optional so a helper-only development install resolves only the helper's actual `zod` runtime dependency; an application composition still supplies the peers used by the package's Cordis entry. - -DSH does not materialize or prepend a prepare executable. `RepositoryCache` supplies only a transaction-owned `pnpm` wrapper: the outer install runs the pinned pnpm entry directly, while pnpm's hard-coded Git-package `pnpm install` reinvokes the same entry with `--ignore-workspace`. The selected package therefore owns dependency resolution even beneath another pnpm lockfile, and normal package-manager lifecycle `PATH` construction exposes `node_modules/.bin/dsh-plugin-prepare`. The temporary pnpm wrapper disappears after the child settles. The repository remains trusted package-manager input: all dependency and lifecycle code executes under the existing trust contract. - -The Node 24 consumer lane passes an exact source derived from the pull request head repository and SHA. It uses the existing private DeepSeek Harness repository rather than creating another repository per run. A job-scoped Git configuration gives the read-only job token access to that exact private source and rewrites pnpm's SSH fallback to authenticated HTTPS. - -The built-entry acceptance also creates an in-process npm registry. It stages the current built `@deepseek-ai/dsh-repository-plugin` as a publication artifact by removing `private`, replacing workspace protocols with the release version, and packing the declared files. The registry serves the resulting packument and tarball, while a job-local npm config directs only the `@deepseek-ai` scope to it. The real built `dsh run` child then fetches the exact Git source; that package resolves the helper through npm, type-checks and bundles a TypeScript Cordis entry and MCP server, prepares the adjacent skill, and loads all three contributions. A deliberately failing host `PATH` command proves the lifecycle selected the dependency-local executable. The acceptance also requires registry resolution and inspects the immutable prepared cache, so restoring a host-injected helper cannot satisfy it. - -## Alternatives considered - -**Inject `dsh-plugin-prepare` from the running DSH installation.** Rejected because it lets an incomplete repository manifest pass and tests a host-only path that npm consumers cannot reproduce. - -**Publish the source fixture itself to npm.** Rejected because the product contract is specifically that the DSH Plugin remains Git-hosted; only the reusable preparation helper is an npm dependency. - -**Create a new private GitHub repository in every CI run.** Rejected because the pull request repository at its exact head SHA is already a real authenticated private Git remote. Per-run repository mutation would add credentials, cleanup, and eventual-consistency failure modes without changing the acquisition path. - -**Prepare after `RepositoryCache` installs the selected package.** Rejected because pnpm's packed subdirectory no longer contains sibling source assets referenced by paths such as `../skills`; preparation must happen before packlist. - -**Clone GitHub repositories in DSH and bypass pnpm's Git fetcher.** Rejected because it would duplicate ref resolution, subdirectory selection, dependency installation, packlist behavior, and cache integrity already owned by the pinned package manager. - -## Consequences - -- A repository author can commit a `.dsh-plugin` package, TypeScript source, skills, and MCP definitions to GitHub without publishing that Plugin package to npm. The package must declare the published preparation dependency. -- Private GitHub sources use the host's standard Git authentication. CI proves that path with a temporary read-only configuration rather than persistent runner credentials. -- `prepack`, not `prepare`, is part of the authoring format. It may contain arbitrary package-owned build steps but must invoke the dependency-provided helper; missing dependency or lifecycle metadata fails before a cache generation is usable. -- A selected package in a pnpm repository installs from its own manifest rather than an enclosing workspace. It cannot rely on workspace-only hoisting; ordinary registry and relative `file:` dependencies remain package-owned inputs. -- Exact source strings identify immutable cache generations; a changed ref or source configuration selects another generation. -- Package dependencies, compilation, preparation, and the trusted `dsh.entry` contribution remain owned by the repository package and the [trusted-code decision](../architecture/2026-08-08-trusted-repository-package-code.md). - -## Testing - -`packages/boot/app-boot/tests/repository-cache.spec.ts` runs a package excluded from its source repository's root pnpm lockfile through a local Git subpath and requires relative `file:` dependencies to provide both its build command and `dsh-plugin-prepare`; it also proves that visible environment survives while credential-shaped variables are scrubbed. `packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts` pins helper-bearing `prepack` metadata and preparation output. `examples/headless-agent/tests/keyless-smoke.e2e.ts` keeps the checked-in prepared fixture on that source contract. `apps/cli/tests/github-repository-plugin.built.e2e.ts` is the product acceptance: simulated published helper package, job-local npm registry, fresh DSH home, exact authenticated private GitHub source, actual built `dsh run`, package-owned TypeScript build, real MCP execution, code-entry transformation, mock LLM request observation, and prepared cache inspection. diff --git a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md b/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md deleted file mode 100644 index d2256e0eae..0000000000 --- a/.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.zh.md +++ /dev/null @@ -1,48 +0,0 @@ -# Agent Note: 基于 NPM 的准备机制使 GitHub repository 插件自包含 - -状态:已实现 - -[English](2026-08-08-npm-backed-git-repository-plugin-preparation.md) | 中文 - -## 问题 - -repository 插件创作约定要求 `scripts.prepack` 调用 `dsh-plugin-prepare`。如果由正在运行的 DSH 安装提供该可执行文件,即使源包自身的 manifest(元数据清单)无法取得辅助程序,它也会显得有效。因此,这并未证明 `@deepseek-ai/dsh-repository-plugin` 发布后用户所需的行为:普通 Git 托管 NPM 包必须只依靠自身声明的依赖即可安装和准备。 - -pnpm workspace 内可选择的 `.dsh-plugin` 还有另一项隔离要求。pnpm 会在打包所选子目录前运行仓库首选的包管理器,以准备 Git 托管包。嵌套的 `pnpm install` 可能加入外层 workspace;当根 lockfile 未把 `.dsh-plugin` 列为 importer 时,pnpm 可能报告成功,却未安装仅由该包声明的依赖。随后,其 TypeScript 构建或准备命令会失败;也可能因为存在预生成产物,依赖缺失被掩盖。 - -签入仓库的 headless fixture(测试前置数据)挂载的是已准备好的包装层。它证明运行时组合,而不证明 GitHub 获取、NPM 解析或包自有准备。 - -## 决策 - -`.dsh-plugin` 包将已发布的 `@deepseek-ai/dsh-repository-plugin` 声明为普通开发依赖,并在 `scripts.prepack` 中调用其已发布的 `dsh-plugin-prepare` 可执行文件。该包可以声明其他任意构建依赖与运行时依赖,并在辅助程序前执行任意编译。repository 插件包把 Cordis 与 DSH 对等依赖(peer dependency)标为可选,因此仅为使用辅助程序而进行的开发安装只会解析辅助程序实际依赖的 `zod` 运行时依赖;应用组合仍会提供该包 Cordis 入口所使用的对等依赖。 - -DSH 不会生成准备阶段可执行文件,也不会将其前置到 `PATH`。`RepositoryCache` 只提供一个由事务持有的 `pnpm` 包装脚本:外层安装直接运行锁定的 pnpm 入口,而 pnpm 为 Git 包硬编码的 `pnpm install` 会以 `--ignore-workspace` 重新调用同一入口。因此,即使位于另一个 pnpm lockfile 之下,所选包仍自行负责依赖解析,正常的包管理器生命周期 `PATH` 构造会暴露 `node_modules/.bin/dsh-plugin-prepare`。临时 pnpm 包装脚本会在子进程结算后消失。repository 仍是受信任的包管理器输入:所有依赖与生命周期代码都按既有信任约定执行。 - -Node 24 消费方 CI 任务会传入从 PR(Pull Request)head 仓库与 SHA 派生的精确源。它复用现有私有 DeepSeek Harness 仓库,而不会为每次运行新建仓库。作业作用域的 Git 配置允许只读作业 token 访问该精确私有源,并把 pnpm 的 SSH 回退改写为已认证 HTTPS。 - -构建入口验收还会创建一个进程内 NPM 注册表。它通过移除 `private`、将 workspace protocol 替换为发布版本并打包声明的文件,把当前已构建的 `@deepseek-ai/dsh-repository-plugin` 暂存为发布产物。注册表会提供由此生成的 `packument` 与 tarball,作业本地 NPM 配置则只把 `@deepseek-ai` scope 指向它。实际构建的 `dsh run` 子进程随后获取精确 Git 源;该包通过 NPM 解析辅助程序,对 TypeScript Cordis 入口和 MCP server 进行类型检查与打包,准备相邻的 skill(技能),并加载全部三类贡献。一个刻意设为失败的宿主 `PATH` 命令可以证明,该生命周期选中的是依赖内的可执行文件。验收还要求经过注册表解析并检查不可变的已准备缓存,因此恢复宿主注入的辅助程序也无法通过。 - -## 考虑过的替代方案 - -**从正在运行的 DSH 安装注入 `dsh-plugin-prepare`。** 拒绝,因为这会让 manifest 不完整的 repository 包通过,并测试 NPM 消费方无法复现的纯宿主路径。 - -**把源 fixture 本身发布到 NPM。** 拒绝,因为产品约定明确要求 DSH 插件仍托管在 Git;只有可复用的准备辅助程序是 NPM 依赖。 - -**在每次 CI 运行中创建新的私有 GitHub 仓库。** 拒绝,因为 PR 仓库的精确 head SHA 已是经过认证的真实私有 Git remote。每次运行的仓库变更会增加凭据、清理和最终一致性失败模式,却不改变获取路径。 - -**在 `RepositoryCache` 安装所选包后再准备。** 拒绝,因为 pnpm 打包后的子目录不再包含 `../skills` 等路径所引用的同仓库相邻资源;准备必须在生成 packlist 前完成。 - -**在 DSH 中克隆 GitHub 仓库并绕过 pnpm 的 Git 获取器。** 拒绝,因为这会重复实现已由锁定包管理器负责的 ref 解析、子目录选择、依赖安装、packlist 行为和缓存完整性。 - -## 后果 - -- 仓库作者可以把 `.dsh-plugin` 包、TypeScript 源码、skill 与 MCP 定义提交到 GitHub,而无需把该插件包发布到 NPM。该包必须声明已发布的准备依赖。 -- 私有 GitHub 源使用宿主的标准 Git 认证。CI 使用临时的只读配置而非运行器上的持久凭据来验证该路径。 -- 创作格式使用 `prepack` 而不是 `prepare`。其中可以包含任意包自有构建步骤,但必须调用依赖提供的辅助程序;依赖或生命周期元数据缺失时,会在缓存 generation 可用前失败。 -- pnpm 仓库中的所选包按自身 manifest 安装,而不继承外层 workspace。它不能依赖仅由 workspace 提升而可见的包;普通注册表依赖和相对 `file:` 依赖仍是包自有输入。 -- 精确源字符串标识不可变缓存 generation;改变 ref 或源配置会选择另一个 generation。 -- 包依赖、编译、准备和受信任的 `dsh.entry` 贡献仍由 repository 包和[受信任代码决策](../architecture/2026-08-08-trusted-repository-package-code.md)负责。 - -## 测试 - -`packages/boot/app-boot/tests/repository-cache.spec.ts` 会通过本地 Git 子路径运行一个未列入源仓库根 pnpm lockfile 的包,并要求相对 `file:` 依赖同时提供构建命令与 `dsh-plugin-prepare`;该测试还证明可见环境变量得以保留,而名称符合凭据模式的变量会被清除。`packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts` 锁定包含辅助命令的 `prepack` 元数据与准备输出。`examples/headless-agent/tests/keyless-smoke.e2e.ts` 使签入仓库的已准备 fixture 继续符合该源格式约定。`apps/cli/tests/github-repository-plugin.built.e2e.ts` 是产品验收测试:模拟发布的辅助程序包、作业本地 NPM 注册表、全新 DSH 主目录、精确且经过认证的私有 GitHub 源、实际构建的 `dsh run`、包自有 TypeScript 构建、真实 MCP 执行、代码入口转换、mock LLM(大语言模型)请求观测,以及已准备缓存检查。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml similarity index 55% rename from .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.i18n.yaml index ff9e0f5bbc..68f399bab8 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.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 .agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md -2026-07-30-static-repository-plugin-format.md: c66ee111eb0cac9e0d6c54581855ffc18efc8611 -2026-07-30-static-repository-plugin-format.zh.md: c85aaf44d96098eb1ccb45456cce1a15e7408fc5 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md +2026-08-10-minimal-preset-owns-rl-composition.md: 002cad0827e969b322997821dc978db85e2955f3 +2026-08-10-minimal-preset-owns-rl-composition.zh.md: e957b57395c68b336695bdae07ea15a54ca1ea4e diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md new file mode 100644 index 0000000000..002cad0827 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md @@ -0,0 +1,39 @@ +# Agent Note: The minimal preset owns the complete RL agent composition + +Status: implemented + +English | [中文](2026-08-10-minimal-preset-owns-rl-composition.zh.md) + +## Problem + +The Web surface offered two owners for the Claude SWE-compatible RL agent: a process-wide `core-web.cordis.yml` patch and the per-session `minimal` preset. Once [agent presets](../architecture/2026-08-03-per-session-agent-presets.md) became the agent-composition boundary, the preset's scoped `deployment:persona` shadowed the overlay's corrected global persona with stale coding-agent text. The overlay test mounted no preset, while the preset test booted without the overlay, so neither exercised the composition users selected. + +The split also hid other drift. The preset mounted one-shot Bash rather than the [persistent Bash](../feature/2026-07-29-persistent-bash-str-replace-editor.md) used by the RL harness and omitted the RL compaction policy. Keeping both owners makes every future prompt, tool, and policy change a cross-product. + +## Decision + +The shipped Web `minimal` preset is the sole Web owner of the RL agent composition. It declares an entry-local PTY registry and local backend, persistent `bash` with the RL environment description and 300-second timeout, `str_replace_editor`, and an entry-local compaction backend. Tool presentation remains a deployment choice. The compaction policy keeps the RL threshold, absolute retention, generation cap, and retry count; model capacity comes from routed adapter metadata because `contextWindow` is no longer a compact-basic config field. The editor accepts no `requireAbsolutePath` setting because absolute paths are its unconditional contract. + +The preset persona is exactly `You are a helpful software engineer assistant.` and sets `complete: true`. A complete `PromptSection` participates in ordinary assembly so tools, contexts, variables, and cooperative listeners still resolve; after the `system-prompt/assemble` waterfall, the prompt registry restores a detached copy of that section as the sole system-prompt section. Multiple effective complete sections reject assembly. This final registry constraint prevents harness identity, Web orientation, tool guidance, or an assembly listener from appending prompt text. + +The process-wide `core-web.cordis.yml` patch is absent. Browser UI, workspace attachment, persistence, filesystem, subprocess, sandbox, permission, model routing, and other cross-session services remain host-owned. Selecting `minimal` changes one agent's model-facing composition without changing other sessions in the Web process. + +## Verification + +System-prompt and persona package tests prove final complete-section enforcement, including waterfall mutation and duplicate rejection. The shipped-preset composition test asserts the exact prompt, Bash description, absolute editor schema, and two-tool catalog under the default native presentation. The keyless Web replay sends a real request through a `minimal` agent while global identity, Web surface text, and a test section are registered, then executes two persistent Bash calls to prove environment and cwd state survive and executes the editor through an absolute path. + +The standalone [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) mirrors the same prompt, tools, timeouts, and compaction policy for the bundled JSON-RPC runtime. Its keyless SDK replay asserts the assembled system prompt and two-tool catalog, executes persistent Bash across calls, and exercises the editor; the Python SDK tutorial provides the runnable entry point. + +## Alternatives considered + +**Keep `core-web.cordis.yml` as a compatibility patch.** Rejected because a process patch and a session preset are two independent owners for one agent contract; precedence makes either one capable of silently undoing the other. + +**Disable every known prompt contributor in the preset.** Rejected because host rows are process-wide and new contributors would reopen the prompt. A final complete-section constraint expresses the negative guarantee at the registry that assembles the prompt. + +**Filter sections only with a prepended waterfall listener.** Rejected because another prepended wrapper can run outside it and append after the filter. Enforcement after the complete waterfall has stable final authority. + +**Mount PTY services on the Web host.** Rejected because only the minimal agent consumes them. An entry-local `pty` realm gives the services the same lifetime and scope as their sole consumer without publishing a process-global service from a preset. + +## Consequences + +The RL prompt is fixed rather than environment-overridable. The Web preset and standalone JSON-RPC example state the same contract for their respective launch surfaces. The model sees only persistent `bash` and `str_replace_editor`; shell state is per agent and disappears with that agent. The preset pays for its own PTY and compaction service instances, while other presets pay nothing for them. The local persistent-shell backend requires the supported POSIX terminal substrate, so this preset is not a Windows agent surface. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md new file mode 100644 index 0000000000..e957b57395 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-minimal-preset-owns-rl-composition.zh.md @@ -0,0 +1,39 @@ +# Agent Note: minimal preset 拥有完整的 RL agent 组合 + +Status: implemented + +[English](2026-08-10-minimal-preset-owns-rl-composition.md) | 中文 + +## 问题 + +Web surface 同时由两个位置定义与 Claude SWE 兼容的 RL agent(智能体):进程级 `core-web.cordis.yml` patch,以及逐会话的 `minimal` preset。[agent preset](../architecture/2026-08-03-per-session-agent-presets.md) 成为 agent 组合边界后,preset 中带作用域的 `deployment:persona` 会用陈旧的 coding-agent 文本遮蔽 overlay 修正过的全局 persona。overlay 测试没有挂载 preset,而 preset 测试启动时没有 overlay,因此两者都没有覆盖用户实际选择的组合。 + +这种拆分还掩盖了其他偏差。preset 挂载了一次性 Bash,而不是 RL harness 使用的[持久 Bash](../feature/2026-07-29-persistent-bash-str-replace-editor.md),并且遗漏了 RL 压缩(compaction)策略。保留两个所有者,会使今后每次修改提示词、工具或策略时都必须验证二者的交叉组合。 + +## 决策 + +随附的 Web `minimal` preset 是 RL agent 组合在 Web 中的唯一所有者。它声明 entry 本地的 PTY 注册表与本地后端、带 RL 环境描述且超时为 300 秒的持久 `bash`、`str_replace_editor`,以及 entry 本地的压缩后端。工具呈现仍由部署选择。压缩策略保留 RL 的阈值、绝对保留量、生成上限和重试次数;模型容量来自经路由选定的适配器元数据,因为 `contextWindow` 已不再是 compact-basic 的配置字段。编辑器不接受 `requireAbsolutePath` 设置,因为要求绝对路径是它的无条件约定。 + +preset persona 恰好是 `You are a helpful software engineer assistant.`,并设置 `complete: true`。complete `PromptSection` 参与常规组装,因此工具、上下文、变量和协作式监听器仍会解析;`system-prompt/assemble` waterfall(瀑布式事件)结束后,提示词注册表会将该段落的独立副本恢复为唯一的系统提示词段落。存在多个有效 complete 段时,组装会被拒绝。这项最终注册表约束可防止 harness 身份、Web 定位、工具引导或组装监听器追加提示词文本。 + +进程级 `core-web.cordis.yml` patch 不再存在。浏览器 UI、workspace 附加、持久化、文件系统、子进程、沙箱、权限、模型路由及其他跨会话服务仍由宿主持有。选择 `minimal` 只会改变一个 agent 面向模型的组合,不会改变 Web 进程中的其他会话。 + +## 验证 + +系统提示词与 persona 包测试证明了 complete 段的最终约束,包括 waterfall 修改与重复项拒绝。交付 preset 组合测试在默认原生呈现下断言精确的提示词、Bash 描述、要求绝对路径的编辑器 schema 和双工具目录。无密钥 Web 回放通过 `minimal` agent 发送一个真实请求,同时注册全局身份、Web surface 文本和一个测试段落;随后执行两次持久 Bash 调用,证明环境与 cwd 状态能够保留,并通过绝对路径执行编辑器。 + +独立的 [`minimal.cordis.yml`](../../../../examples/jsonrpc-agent/minimal.cordis.yml) 为内置 JSON-RPC 运行时复现相同的提示词、工具、超时和压缩策略。其无密钥 SDK 回放会断言组装后的系统提示词与双工具目录,跨调用执行持久 Bash,并使用编辑器;Python SDK 教程提供可运行的入口。 + +## 考虑过的替代方案 + +**将 `core-web.cordis.yml` 保留为兼容 patch。** 被拒绝,因为进程 patch 与会话 preset 是同一 agent 约定的两个独立所有者;优先级会使任意一方都能静默撤销另一方的配置。 + +**在 preset 中禁用每个已知的提示词贡献方。** 被拒绝,因为宿主行属于整个进程,新的贡献方也会重新开放提示词。由组装提示词的注册表实施最终 complete 段约束,才能表达这项否定保证。 + +**仅使用前置 waterfall 监听器筛选段落。** 被拒绝,因为另一个前置包装层可以在该监听器外执行,并在筛选后追加内容。在整个 waterfall 结束后实施约束,才能稳定拥有最终决定权。 + +**在 Web 宿主上挂载 PTY 服务。** 被拒绝,因为只有 minimal agent 消费这些服务。entry 本地的 `pty` realm 与唯一消费方具有相同的生命周期和作用域,无需由 preset 发布进程级全局服务。 + +## 后果 + +RL 提示词固定不变,不能通过环境覆盖。Web preset 与独立 JSON-RPC 示例分别在各自的启动界面声明相同的约定。模型只看到持久 `bash` 与 `str_replace_editor`;shell 状态按 agent 隔离,并随该 agent 一并消失。preset 为自身的 PTY 与压缩服务实例承担开销,其他 preset 无需承担。持久 shell 的本地后端需要受支持的 POSIX 终端基础环境,因此该 preset 不适用于 Windows agent surface。 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml new file mode 100644 index 0000000000..612916a290 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.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-08-10-subagent-empty-terminal-message-output.md +2026-08-10-subagent-empty-terminal-message-output.md: 693013f6810005ce02b08bd82f1f6a18511c40fb +2026-08-10-subagent-empty-terminal-message-output.zh.md: 64d61af21f838ef3f515db8af116cbdd74e96179 diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md new file mode 100644 index 0000000000..693013f681 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md @@ -0,0 +1,31 @@ +# Agent Note: One selection rule keeps subagent output past an empty terminal message + +Status: implemented + +English | [中文](2026-08-10-subagent-empty-terminal-message-output.zh.md) + +## Problem + +The agent loop appends an empty-content `assistant/message` when a `max-tokens` step assembled only tool-call blocks because `BlockAssembler.blocks()` drops truncated tool calls; the message records usage only. Three consumers selected the child's output independently and treated that usage record as output. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture selected the last `assistant/message` without filtering, while the SDK backend's observer let any `assistant/message` take precedence over accumulated text. In a multi-step turn cut off at max-tokens, the final empty message caused the real partial answer to be omitted from `SubagentResult.output`, the tool result, telemetry, and `subagent/end.lastAssistantMessage`. The in-process driver also lacked a streamed-text fallback, so a cancelled child whose only text existed in `assistant/chunk` events reported `[]`. + +## Decision + +`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: select the last non-empty assistant message; without one, select the accumulated `text-delta` stream; ignore empty-content messages. The incremental `AssistantOutputFold` implements the rule through `push(event)` for session-event transports, `pushText(text)` for chunk-only transports, and `collect()` for selection. `finalAssistantOutput(events)` applies it to a complete event suffix for the in-process `readResult` and Activation capture. The SDK backend folds notification events; the ACP backend exposes no complete assistant messages and folds raw chunk text. `SubagentResult.output` defines the result contract, and `subagent/end.lastAssistantMessage` uses the same rule. When a child produces neither form of output, the lifecycle field is absent rather than an empty array for both one-shot and continuable runs. A `max-tokens` or `aborted` result retains its actual stop reason. + +The foreground delegation tool uses the same selection. A non-`completed` result remains an `isError` tool result, but its message appends the child's partial text after the stop-reason headline so the parent model receives both the failure and available output. + +## Verification + +The keyless SDK backend test uses `FAKE_EMPTY_MESSAGE` to emit a usage-only terminal message. The `subagent-max-tokens-partial` ACP snapshot records a child that streams text and a tool call, ends at a tool-only max-tokens step with an empty usage message in its durable log, and returns the partial text through the parent's errored tool result. Unit coverage checks empty terminal messages, cancellation, message ordering, textless non-empty messages, and exclusion of tool-result content. + +## Alternatives considered + +**Fix each consumer in place without a shared helper.** Rejected: three independent selections had diverged, while observers of one run must agree on its output. + +**Stop the loop from appending the empty message.** Rejected: the message records usage and preserves the step in the durable log ("model-visible ⟺ logged"); changing session events to address output selection would affect every replay and projection consumer. + +**Treat empty-content messages as an error.** Rejected: the streamed text is the child's real partial answer, and the stop reason already tells the consumer the turn was cut short. + +## Consequences + +Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children retain text streamed before the abort; one-shot and continuable `subagent/end` events agree with `SubagentResult.output`. A message whose content is non-empty but textless, such as reasoning-only content, is selected instead of streamed text because the rule tests content length rather than text presence. A non-empty message is also selected instead of text streamed after it: a child cancelled while streaming a later step reports its earlier complete message, while the stop reason records the truncation. diff --git a/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md new file mode 100644 index 0000000000..64d61af21f --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 用同一条选取规则在空终止消息后保留子代理输出 + +Status: implemented + +[English](2026-08-10-subagent-empty-terminal-message-output.md) | 中文 + +## 问题 + +当 `max-tokens` 步骤只组装了工具调用块时,agent loop(智能体循环)会追加一条空内容的 `assistant/message`,因为 `BlockAssembler.blocks()` 会丢弃被截断的工具调用;这条消息仅记录 usage。三个消费方独立选取子 agent 的输出,并把这条 usage 记录当成输出。进程内驱动的 `readResult` 与 continuable Activation 的 `subagent/end` capture 不加过滤地选取最后一条 `assistant/message`,SDK 后端的观察器则让任何 `assistant/message` 优先于累积的文本。在被 max-tokens 截断的多步轮次中,最后那条空消息导致 `SubagentResult.output`、工具结果、遥测与 `subagent/end.lastAssistantMessage` 都漏掉真实的部分回答。进程内驱动也没有流式文本兜底,因此被取消的子 agent 若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`。 + +## 决策 + +`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:选取最后一条非空 assistant 消息;没有时选取累积的 `text-delta` 流;忽略空内容消息。增量的 `AssistantOutputFold` 通过 `push(event)` 处理会话事件传输,通过 `pushText(text)` 处理仅分片传输,并通过 `collect()` 完成选取。`finalAssistantOutput(events)` 把规则应用于完整的事件后缀,供进程内 `readResult` 与 Activation capture 使用。SDK 后端折叠通知事件;ACP 后端不暴露完整的 assistant 消息,而是折叠原始分片文本。`SubagentResult.output` 定义结果约定,`subagent/end.lastAssistantMessage` 使用同一规则。子 agent 不产生这两种输出中的任何一种时,一次性与 continuable 运行的生命周期字段都会缺省,而不是空数组。`max-tokens` 或 `aborted` 结果保留实际的终止原因。 + +前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后附上子 agent 的部分文本,让父模型同时接收失败信息与已有输出。 + +## 验证 + +无密钥 SDK 后端测试使用 `FAKE_EMPTY_MESSAGE` 发出一条仅记录 usage 的终止消息。`subagent-max-tokens-partial` ACP 快照记录一个子 agent:它流式输出文本与一次工具调用,结束于仅含工具调用的 max-tokens 步骤,持久化日志中含一条空的 usage 消息,并通过父侧的错误工具结果返回部分文本。单元覆盖检查空终止消息、取消、消息顺序、不含文本的非空消息,以及排除工具结果内容。 + +## 考虑过的替代方案 + +**各消费方就地修复、不抽共享辅助函数。** 之所以否决:三处独立选取已发生分歧,而同一次运行的观察方必须对其输出达成一致。 + +**让 loop 不再追加空消息。** 之所以否决:这条消息记录 usage,并在持久化日志中保留该步骤("model-visible ⟺ logged");为处理输出选取而改动会话事件,会影响所有 replay 与 projection 消费方。 + +**把空内容消息视为错误。** 之所以否决:流式文本才是子代理真实的部分回答,且终止原因已经告诉消费方轮次被截断。 + +## 后果 + +被 max-tokens 截断的多步子 agent 会报告其更早的文本;被取消的进程内子 agent 保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 事件同 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning 的内容)仍然优先于流式文本,因为规则检查内容长度,而不是文本是否存在。非空消息同样优先于其后才流式出的文本:子 agent 在流式输出后续步骤时被取消,报告的是更早那条完整消息,终止原因则记录该截断。 diff --git a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml b/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml similarity index 55% rename from .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml rename to .agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.i18n.yaml index 9d49d8b1c2..21b6957cd2 100644 --- a/.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.i18n.yaml +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.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 .agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md -2026-08-08-trusted-repository-package-code.md: 387479b3b36a8bc5e145641ae40802b3090ced70 -2026-08-08-trusted-repository-package-code.zh.md: ecc325c3dd0a9e823f1411c3a809c30ada486289 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md +2026-08-11-preset-card-description-clamp.md: 16ebf371d5af7c9e54fcc37819696b380856d5cb +2026-08-11-preset-card-description-clamp.zh.md: 5b7a18f41e4b3e8acd681a001f6826b19ca7026d diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md new file mode 100644 index 0000000000..16ebf371d5 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.md @@ -0,0 +1,43 @@ +# Agent Note: Preset cards clamp their description instead of sizing the roster + +Status: implemented + +English | [中文](2026-08-11-preset-card-description-clamp.zh.md) + +## Problem + +A preset publishes its own `description`, of any length, and the settings section renders the roster as a card grid. The description had a `min-height` and no upper bound, while the grid sizes rows with `grid-auto-rows: 1fr` — which makes every implicit row the same height, not just the row holding the tall card. One long description therefore set the height of the whole roster: with a 250-character description in the custom group, all four cards measured 421px and the short-description cards filled with blank space. + +The description is also the field that tells presets apart, so hiding it is not an option; the card has to bound it and still make the whole text reachable. + +## Decision + +The description clamps to four lines and offers the rest through the shared `Tooltip`, attached only while the element actually overflows (`scrollHeight > clientHeight`, re-measured through a ResizeObserver because the settings pane width follows the window). This mirrors the chat stats line, which clamps to one line on the same measure-then-attach rule. + +Card height stays derived rather than fixed. With the description bounded, `grid-auto-rows: 1fr` already equalizes the grid, and a card carrying the broken-preset reason or a revealed path still sizes itself — a pixel height would clip both. + +Three smaller decisions ride along: + +- `.cardId` takes the card's free space with `margin-top: auto`, and the description no longer grows. A flex-stretched box leaves the clamp height and the box height disagreeing; sizing the clamped box by content alone keeps the behavior independent of that interaction. +- The description carries `title=""`. An empty `title` means the element has no advisory information and the lookup stops there, so the card body's native tooltip does not climb to the description and a cut-off description answers with one bubble instead of two. +- `Tooltip` gains an optional `maxWidth`. Its default half-viewport cap renders a description as a slab wider than the settings dialog it belongs to, spilling across the application behind it. +- `Tooltip` also flips a `top` or `bottom` bubble to the other side when the viewport has no room for it, which its horizontal-only clamp previously left unhandled. Custom presets sit at the bottom of the roster and carry the longest descriptions, so the common case put a tall bubble under an anchor low on the page. The flip only moves into a side that genuinely fits, so an anchor with room on neither side keeps the requested placement rather than oscillating; sliding the bubble vertically instead would cover the text being read. + +A roster row that failed its shape check is badged `Failed to load` (`加载失败`) rather than `Broken` (`已损坏`). Discovery sets `broken` when the composition file is missing, unreadable, or malformed — most often a file the user just edited or deleted — so a damage claim overstates what was observed, and the verbatim reason under the badge already names the file and the fix. + +## Alternatives considered + +- **A fixed card height.** It states the intent directly but clips the two rows whose height legitimately varies: the broken-preset reason and the revealed preset directory. +- **The native `title` attribute carrying the full description.** No measurement and no component, but a roughly one-second delay, operating-system styling, and it takes over the card's `set as default` hint across most of the card's area. +- **Attaching the tooltip unconditionally.** It drops the ResizeObserver, at the cost of answering a hover over a short description with a bubble repeating what is already on the card. +- **Expanding the clamp on hover.** It shows the text in place, and moves the grid under the pointer. + +## Consequences + +The section owns a small measured component and the shared primitive owns one more optional prop. In exchange, no card's height follows the longest description anywhere in the roster, and the whole description stays in the accessibility tree because the clamp is CSS rather than truncated text. + +The `title=""` suppression is pinned by a DOM assertion, not by observing the native tooltip: a browser tooltip is drawn outside the page and cannot be captured. If a browser ever resumes climbing past an empty `title`, the fallback is to drop the card body's `title` — its content is already in the body's `aria-label`. + +## Testing + +Package tests cover the three measurement outcomes (cut off, fitting, and a runtime without `ResizeObserver`) and the tooltip width cap. The web e2e goldens replay unchanged except `damaged.expected.md`, re-recorded for the badge copy. diff --git a/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md b/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md new file mode 100644 index 0000000000..5b7a18f41e --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-11-preset-card-description-clamp.zh.md @@ -0,0 +1,43 @@ +# Agent Note: 预设卡片截断自身描述,而不是由描述决定整份名单的高度 + +Status: implemented + +[English](2026-08-11-preset-card-description-clamp.md) | 中文 + +## 问题 + +preset 自行发布 `description`,长度不限,而设置分区把名单渲染为卡片网格。描述只有 `min-height` 没有上限,网格则以 `grid-auto-rows: 1fr` 排布行——该取值让每一个隐式行等高,而不只是承载高卡片的那一行。因此一条长描述决定了整份名单的高度:自定义组里放入一条 250 字的描述后,四张卡片全部量得 421px,短描述卡片被大片空白填满。 + +描述同时又是区分各个 preset 的字段,因此不能藏起来;卡片必须既给它设上限,又让全文仍然可达。 + +## 决定 + +描述截断为四行,其余内容通过共享的 `Tooltip` 呈现,且仅在元素确实溢出时才挂载(`scrollHeight > clientHeight`,并经 ResizeObserver 重新测量,因为设置面板宽度跟随窗口)。这与聊天统计行一致:它按同样的「先测量再挂载」规则截断为一行。 + +卡片高度仍是推导得出而非固定。描述有了上限之后,`grid-auto-rows: 1fr` 本身就让网格等高,而承载损坏原因或已展示目录的卡片仍能按自身内容定高——写死像素高度会把两者一并裁掉。 + +随之而来三个更小的决定: + +- `.cardId` 以 `margin-top: auto` 吃掉卡片的空余空间,描述不再拉伸。被 flex 拉伸的盒子会让截断高度与盒子高度不一致;让截断盒子只按内容定高,行为便不依赖这层交互。 +- 描述带有 `title=""`。空 `title` 表示该元素没有提示信息,查找就此停止,因此卡片主体的原生 tooltip 不会向上找到描述,被裁切的描述只回应一个气泡而不是两个。 +- `Tooltip` 新增可选的 `maxWidth`。它默认的半视口上限会把描述渲染成比所属设置弹窗还宽的一整块,溢出到背后的应用界面上。 +- `Tooltip` 同时在视口放不下时把 `top` 或 `bottom` 气泡翻到另一侧,此前它只做水平收敛。自定义 preset 位于名单末尾、又恰恰承载最长的描述,因此常见情形正是让一个高气泡挂在页面靠下的锚点之下。翻转只会移向确实放得下的一侧,两侧都放不下时保持请求的位置而不来回摆动;改为垂直滑动则会盖住正在阅读的文本。 + +形状检查未通过的名单行,徽记从 `Broken`(`已损坏`)改为 `Failed to load`(`加载失败`)。discovery 在组装文件缺失、读不出或格式错误时置位 `broken`——最常见的是用户刚编辑或删除的文件——因此断言损坏超出了观察到的事实,而徽记下方原样展示的原因本就点名了文件与修法。 + +## 备选方案 + +- **写死卡片高度。** 它直接表达了意图,却会裁掉两处高度本就可变的行:损坏预设的原因行和已展示的预设目录。 +- **用原生 `title` 属性承载完整描述。** 无需测量也无需组件,代价是约一秒的延迟、操作系统的样式,以及在卡片大部分区域内顶替掉「设为默认」的提示。 +- **无条件挂载 tooltip。** 省掉 ResizeObserver,代价是把鼠标停在短描述上时,弹出一个重复卡片已有内容的气泡。 +- **hover 时展开截断。** 它就地展示文本,同时让网格在指针下方发生位移。 + +## 后果 + +分区多了一个带测量的小组件,共享基元多了一个可选 prop。换来的是:任何卡片的高度都不再跟随名单中最长的那条描述;而且截断由 CSS 完成而非截短文本,完整描述始终留在无障碍树中。 + +`title=""` 的抑制作用由一条 DOM 断言钉住,而非通过观察原生 tooltip:浏览器 tooltip 画在页面之外,无法被捕获。若某个浏览器日后重新越过空 `title` 继续向上查找,退路是去掉卡片主体的 `title`——它的内容已经在主体的 `aria-label` 里。 + +## 测试 + +包内测试覆盖三种测量结果(被裁切、放得下、运行时没有 `ResizeObserver`)以及 tooltip 的宽度上限。web e2e golden 除 `damaged.expected.md` 按徽记文案重录外,其余原样回放通过。 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml index c1ba01b255..ea386ac60b 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-approval-seam.md -2026-07-06-approval-seam.md: 7c830d93f19a40ab193cfebabca854882ab68d62 -2026-07-06-approval-seam.zh.md: 9dedfddadc23b0da44b28e8750508653ee20bb83 +2026-07-06-approval-seam.md: 8aa9986139dae77e08c166b72545bfa688a389e0 +2026-07-06-approval-seam.zh.md: ef4ccf5fd2b54888a648737866ff6f5fe1678882 diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md index 7c830d93f1..8aa9986139 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.md @@ -123,7 +123,7 @@ Costs and accepted limits: - **Who decides whether a call asks in the first place?** Policy producers: a hook returning `permissionDecision: ask`, any `tools/pre-execute` listener, or the sandbox escalation gate. The seam and the bridge only route and answer; neither injects its own judgment about what deserves a prompt. - **What happens when the user dismisses the prompt, or the turn aborts mid-ask?** Dismissal maps to `cancelled` with its own deny text. An already-aborted signal settles `cancelled` without dispatching; an abort during the ask discards the late answer. When both audit appends commit, either path records one pair, never two. - **What if the client answers with an option the harness never offered?** Any selection other than the offered `allow_once` maps to `rejected` — an unknown optionId from a non-conforming client can never grant. -- **How do subagents' approvals route?** An agent no answerer owns delegates through the whole waterfall and fails closed — in-process subagents are deliberately unanswerable. A `'never'` parent seeds that override into each in-process child's log ([decision](2026-07-25-subagent-policy-inheritance.md)), so the child is told up front instead of asking into the empty waterfall. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred). +- **How do subagents' approvals route?** They do not: delegation pins every in-process child to `'never'` ([approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md)), so each child ask resolves `rejected` before any answerer and the child is told up front through its runtime context. `subagent-acp`'s child-side auto-answer is separate; routing a child's asks to the parent controller is deferred (§ Deferred). - **What does `policy: 'never'` actually change at runtime?** The service resolves every ask for that session to `rejected` before dispatching any answerer (in-service, so no registration order can bypass it); the next atomic runtime-context snapshot states the policy; each successful auto-rejection records the audit pair. - **What happens across a hot reload, or when an answerer unloads mid-session?** Answerers dispose with their owning fiber, so the next ask degrades to `unavailable` instead of hanging on a dead channel; remounting re-registers the answerer with no catch-up state. - **Where does a client get approval context?** The request carries the exact `callId` and the asker's human-readable `reason`; channel adapters may correlate richer tool-call state without duplicating arguments in the approval seam. diff --git a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md index 9dedfddadc..ef4ccf5fd2 100644 --- a/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-approval-seam.zh.md @@ -123,7 +123,7 @@ ACP 桥只应答其会话映射所拥有的精确 agent 对象。它携带既有 - **谁决定一次调用是否需要 ask?** 策略生产者:返回 `permissionDecision: ask` 的钩子、任何 `tools/pre-execute` 监听器、或沙箱升级门禁。seam 和桥只负责路由和应答;二者都不注入自己对「什么值得弹出提示」的判断。 - **用户关闭提示或轮次在 ask 进行中中止时会发生什么?** 关闭映射为 `cancelled` 并携带自己的拒绝文本。已中止的 signal 直接结算为 `cancelled` 而不派发;ask 进行中的中止丢弃迟到的应答。当两个审计追加都提交时,任一路径都记录恰好一对事件,绝不会两对。 - **如果客户端以 harness 从未提供的选项应答呢?** 除已提供的 `allow_once` 之外的任何选项都映射为 `rejected`——来自不合规客户端的未知 optionId 永远不能授权。 -- **subagent 的审批如何路由?** 没有应答者拥有的 agent 穿过整个 waterfall 委派并失败关闭——进程内 subagent 被刻意设计为不可应答。`'never'` 父级会把该覆盖项预置到每个进程内子 agent 的日志中([决策](2026-07-25-subagent-policy-inheritance.md)),因此子 agent 一开始就会得知,而不是向空的 waterfall 发出 ask。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。 +- **subagent 的审批如何路由?** 不路由:委派会把每个进程内子 agent 钉定为 `'never'`([审批钉定决策](2026-08-10-subagent-approval-pinned-never.md)),因此子 agent 的每次 ask 都在任何应答者之前解析为 `rejected`,子 agent 则通过其运行时上下文一开始就会得知。`subagent-acp` 的子侧自动应答是独立的;将子 agent 的 ask 路由到父控制器已延后(§ 延后)。 - **`policy: 'never'` 在运行时实际改变了什么?** 服务在派发任何应答者之前,将该会话的每次 ask 解析为 `rejected`(在服务内部,因此没有注册顺序能绕过它);下一份原子化的运行时上下文快照会声明该策略;每次成功的自动拒绝都会记录审计对。 - **热重载或应答者在会话中途卸载时会发生什么?** 应答者随其拥有的 fiber 一起 dispose,因此下一次 ask 降级为 `unavailable` 而非挂在死通道上;重新挂载会重新注册应答者,无需追赶状态。 - **客户端从哪里获得审批上下文?** 请求携带精确的 `callId` 和发起方的人类可读 `reason`;通道适配器可自行关联更丰富的工具调用状态,而无需在审批 seam 中重复携带参数。 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml index 289e9a582a..85fdd2b82c 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-06-sandbox.md -2026-07-06-sandbox.md: 0214a202a41983c76fa25e3a82e1cfaec70a0d55 -2026-07-06-sandbox.zh.md: bd8dcdb74f723a955ea2a1cc5b224ef2ded4a8d5 +2026-07-06-sandbox.md: e1ec35cdcf3be2af03232f823d9a9cee57b4e8e8 +2026-07-06-sandbox.zh.md: d3b74d441f72fc4c218979c327546031fd1b24f1 diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.md index 0214a202a4..e1ec35cdcf 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.md @@ -171,7 +171,7 @@ Costs and accepted limits: - **The one-wrapper illusion is given up knowingly.** A `tools/pre-execute` wrapper plus prompt conventions does not solve sandbox approval — the correct design costs structured denials, native runner probes, per-call policy carriage, and consistent cross-family enforcement, and this design pays it. - **`read-only` became a cross-family boundary through a follow-up.** This RFC shipped bash-only enforcement; the [cross-family fs sandbox RFC](2026-07-14-cross-family-fs-sandbox.md) extends the same mode vocabulary to the filesystem tools through a sandboxed `ctx.fs` provider and relocates the mode/root config and the `sandbox/mode` override to `ctx.sandboxPolicy` (§ In-process tools). -- **Windows has no backend.** Its chain slot is reserved empty — fail-closed, never a fallthrough; filling it is a deferred phase. +- **Windows is a partial backend.** This RFC originally reserved an empty, fail-closed win32 chain; the later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled it with the restricted-token runner. Its Everyone and hard-link gaps are reported as `enforcement: 'partial'`, never promoted to the full promise. - **The Seatbelt rung leans on Apple's deprecated-but-shipped `sandbox-exec` CLI.** As darwin's sole candidate it is selected without probing, so a future removal under a usable workdir surfaces as a runner-attributable spawn failure and an executable refusal through its fatal signature — both become `SANDBOX_UNAVAILABLE`, and the command never runs; fail closed, never open. - **Landlock confinement is only as complete as the running kernel's ABI.** Reported as `enforcement: 'partial'` rather than refused — the deliberate trade that keeps the fallback available on older-kernel hosts. - **Runner attribution uses an in-band protocol.** Exit status plus stderr cannot cryptographically identify the writer, so a confined child can mimic a fatal runner line and status to cause an availability/diagnostic false attribution. The conjunction and exact notice exclusion reduce accidental matches; this is not a sandbox bypass because the child is already confined. diff --git a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md index bd8dcdb74f..d3b74d441f 100644 --- a/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-07-06-sandbox.zh.md @@ -171,7 +171,7 @@ fs/web/todo 在进程内执行,因此它们的沙箱语义是各自能力边 - **单一包装的幻觉被有意放弃。**`tools/pre-execute` 包装加提示词约定无法解决沙箱批准——正确的设计需要结构化拒绝、原生 runner 探测、按调用策略承载和一致的跨工具族强制,本设计为此付出了代价。 - **`read-only` 通过后续设计成为跨工具族边界。** 本 Agent Note 最初只交付 bash 强制;[跨工具族 fs 沙箱 Agent Note](2026-07-14-cross-family-fs-sandbox.md) 通过沙箱化的 `ctx.fs` 提供方把同一模式词汇扩展到文件系统工具,并将 mode/root 配置和 `sandbox/mode` 覆盖迁移到 `ctx.sandboxPolicy`(§ 进程内工具)。 -- **Windows 没有后端。** 其链槽保留为空——失败关闭,绝不穿透;填充它是延迟阶段。 +- **Windows 后端只提供部分强制执行。** 本 RFC 最初预留了一条空的、失败关闭的 win32 链;后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.md)以受限令牌 runner 填充了它。其 Everyone 与硬链接缺口报告为 `enforcement: 'partial'`,绝不提升为完整承诺。 - **Seatbelt 层级依赖 Apple 已弃用但仍交付的 `sandbox-exec` CLI。** 作为 darwin 的唯一候选,它无需探测即被选中,因此在 workdir 可用时,未来移除会表现为可归因于 runner 的 spawn 失败,可执行文件拒绝则通过其致命签名体现——两者都会变为 `SANDBOX_UNAVAILABLE`,且命令绝不会运行;失败关闭,绝不开放。 - **Landlock 约束的完整度取决于运行内核的 ABI。** 报告为 `enforcement: 'partial'` 而非拒绝——这是有意的权衡,使备选在旧内核主机上仍可用。 - **Runner 归因使用带内协议。** 退出状态与 stderr 无法以密码学方式识别写入者,因此受限子进程可以模仿 runner 的致命诊断行和状态,造成可用性或诊断误归因。多项证据的合取与精确通知排除减少了意外匹配;这不是沙箱绕过,因为子进程已经受到限制。 diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml index ca61a3783c..678ae169cf 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md -2026-07-07-mcp-client-plugin.md: 756a5c4dc9f1152ecb1955d93b8dc47fcf07c661 -2026-07-07-mcp-client-plugin.zh.md: 9f45203c479379087c5a19eccce3ab7a339d4ed0 +2026-07-07-mcp-client-plugin.md: 077d978d8815d759574f89ed524ab3bc8c24a267 +2026-07-07-mcp-client-plugin.zh.md: 8f58c9359ca8447717cc353f97f1333370fa6fda diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md index 756a5c4dc9..077d978d88 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.md @@ -153,13 +153,7 @@ Build the child environment from the subprocess seam's shared `scrubbedParentEnv ### Disconnection / crash -No auto-reconnect. If the MCP server process exits or the transport closes: - -1. The effect disposes → all registered tools are unregistered (fiber-scoped disposers). -2. Subsequent model calls to those tools → `ToolNotFoundError` → `isError: true`. -3. Recovery: user edits `cordis.yml` (triggers HMR reload) or restarts the harness. - -This matches the ACP subagent pattern: "crash = terminal, report error, clean up, don't retry." +A per-instance connection supervisor reconnects automatically after a lost connection with bounded exponential backoff and a per-outage attempt budget, re-running discovery on success; exhaustion unregisters the server's tools and stops until reload. The [auto-reconnect Agent Note](2026-08-06-mcp-client-auto-reconnect.md) owns that decision, including the `reconnect` config block and the `reconnect.enabled: false` opt-out that restores manual HMR/restart recovery. ## Alternatives considered @@ -173,7 +167,7 @@ Rejected. There is no foreseeable alternative MCP client implementation — MCP ### Auto-reconnect with exponential backoff -Rejected for v1. Adds complexity (partial-availability state where tools are registered but temporarily non-functional), and stdio process crashes usually indicate a configuration problem that retrying won't fix. HMR already provides the manual recovery path. Can be added as a future `reconnect: boolean` config if needed. +Rejected for v1: it added a partial-availability state (tools registered but temporarily non-functional), and stdio crashes often indicate configuration problems retrying cannot fix; HMR was the recovery path. Operational feedback reversed the deferral — the [auto-reconnect Agent Note](2026-08-06-mcp-client-auto-reconnect.md) implements it with a bounded per-outage budget and an opt-out. ### Bridge Resources and Prompts @@ -211,4 +205,4 @@ Coverage is named per tier; each behavior lives at the cheapest tier that can ex - **MCP SDK stability**: the `@modelcontextprotocol/sdk` is still evolving; breaking changes require updating the bridge. The version is pinned, and the SDK is widely adopted (Claude Desktop, Cursor, VS Code) so breaking changes are unlikely to be silent. - **Tool schema quality**: MCP servers may expose poorly-described tools (vague descriptions, incomplete JSON schemas). The harness passes them through as-is — garbage-in-garbage-out; that is the server author's responsibility, not the bridge's. - **Stdio process management**: a misbehaving MCP server that ignores signals could wedge dispose. The Cordis fiber disposal has bounded quiescence; a stuck transport eventually times out at the framework level. -- Crash recovery is manual (HMR edit or restart) — accepted for v1; a `reconnect` config remains open as future work. +- Crash recovery is automatic within the [reconnect budget](2026-08-06-mcp-client-auto-reconnect.md); manual reload remains the path after exhaustion or with `reconnect.enabled: false`. diff --git a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md index 9f45203c47..8f58c9359c 100644 --- a/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md +++ b/.agents/notes/implemented/feature/2026-07-07-mcp-client-plugin.zh.md @@ -153,13 +153,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp ### 断连 / 崩溃 -不自动重连。如果 MCP 服务器进程退出或传输层关闭: - -1. effect dispose → 所有已注册工具被注销(fiber 作用域的 disposer)。 -2. 后续模型对这些工具的调用 → `ToolNotFoundError` → `isError: true`。 -3. 恢复:用户编辑 `cordis.yml`(触发 HMR 重载)或重启 harness。 - -这与 ACP subagent 模式一致:「崩溃即终态,报告错误,清理资源,不重试。」 +每个实例的连接监督器在连接丢失后以有界指数退避和单次故障尝试预算自动重连,成功后重新执行发现流程;尝试耗尽则注销该服务器的工具并停止,直到重新加载。[自动重连 Agent Note](2026-08-06-mcp-client-auto-reconnect.md) 拥有该决策,包括 `reconnect` 配置块和恢复手动 HMR/重启恢复的 `reconnect.enabled: false` opt-out。 ## 曾考虑的替代方案 @@ -173,7 +167,7 @@ MCP 仅保证工具名在[单个服务器内](https://modelcontextprotocol.io/sp ### 指数退避自动重连 -v1 否决。引入复杂性(工具已注册但暂时不可用的部分可用状态),且 stdio 进程崩溃通常表明配置问题,重试无法修复。HMR 已提供手动恢复路径。如有需要,可在未来作为 `reconnect: boolean` 配置项添加。 +v1 否决:引入了部分可用状态(工具已注册但暂时不可用),且 stdio 崩溃往往表明配置问题,重试无法修复;HMR 曾是恢复路径。运营反馈扭转了该延期决定——[自动重连 Agent Note](2026-08-06-mcp-client-auto-reconnect.md) 以有界的单次故障预算和 opt-out 实现了自动重连。 ### 桥接 Resources 和 Prompts @@ -211,4 +205,4 @@ v1 否决。它能防止跨服务器冲突,但无法将 MCP 注册与原生 ha - **MCP SDK 稳定性**:`@modelcontextprotocol/sdk` 仍在演进中;破坏性变更需要更新桥接。版本已固定,且该 SDK 被广泛采用(Claude Desktop、Cursor、VS Code),因此破坏性变更不太可能悄然发生。 - **工具 schema 质量**:MCP 服务器可能暴露描述不佳的工具(模糊的描述、不完整的 JSON Schema)。harness 原样透传——垃圾进垃圾出;这是服务器作者的责任,不是桥接的。 - **Stdio 进程管理**:行为异常的 MCP 服务器如果忽略信号,可能卡住 dispose。Cordis fiber 的 dispose 具有有界的完全停稳过程;卡住的传输层最终会在框架层面超时。 -- 崩溃恢复是手动的(HMR 编辑或重启)——v1 已接受;`reconnect` 配置作为未来工作保持开放。 +- 崩溃恢复在[重连预算](2026-08-06-mcp-client-auto-reconnect.md)内自动进行;耗尽后或配置 `reconnect.enabled: false` 时回退为手动重新加载。 diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml index 3023f86bba..da61b3fa95 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md -2026-07-08-self-referential-cordis-toolset.md: 5fc2fb07fcd0b00bf72c818d3806b298312cdc31 -2026-07-08-self-referential-cordis-toolset.zh.md: 8f34c97d94cad9b79a0e823406c07cdcfb38793f +2026-07-08-self-referential-cordis-toolset.md: 0d78e0adff487edae00c2422acc1ef8941e7636a +2026-07-08-self-referential-cordis-toolset.zh.md: 665387a9fb24bf0e6fc04fdfb1ced88b932742ad diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md index 5fc2fb07fc..0d78e0adff 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md @@ -40,7 +40,7 @@ The boundary normalizes unambiguous JSON-Schema forms into `ParameterSchemaSpec` Every temporary Plugin is a child of one internal `cordis-dynamic` group beneath the tool plugin, so ordinary fiber disposal handles toolset reload and unload. `cordis_mount` awaits settlement; startup failure disposes the fiber before returning an error. A settled pending Plugin remains visible with its missing injections. `cordis_unmount` awaits the Plugin fiber's disposal. -Temporary Plugins exist only in process memory. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and have no automatic save, promote, or install path. Keeping an experiment means asking the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. +Temporary Plugins exist only in process memory. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and have no automatic save, promote, or install path. Keeping an experiment means asking the Agent to implement a normal project Plugin or installable profile bundle through the regular development workflow. ### Cross-mount composition via provide/inject diff --git a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md index 8f34c97d94..665387a9fb 100644 --- a/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md +++ b/.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.zh.md @@ -40,7 +40,7 @@ vm 隔离了意外的全局污染,上下文门面隐藏了框架内部细节 每个临时 Plugin 都是工具插件下方内部 `cordis-dynamic` 分组的子节点,因此普通的 fiber 释放即可处理工具集重载和卸载。`cordis_mount` 会等待 settlement;启动失败时在返回错误前释放 fiber。已 settle 但处于 pending 状态的 Plugin 仍然可见,并列出其缺失的注入。`cordis_unmount` 等待 Plugin fiber 的释放完成。 -临时 Plugin 只存在于进程内存中。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不存在自动保存、转正式或安装路径。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的本地、项目或仓库 Plugin。 +临时 Plugin 只存在于进程内存中。它不会创建 Plugin 文件、安装 package、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不存在自动保存、转正式或安装路径。若要保留实验结果,应让 Agent 通过常规开发流程实现普通的项目 Plugin 或可安装的 profile 组合包。 ### 通过 provide/inject 实现跨挂载组合 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml index 527dd9bead..830b6467af 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md -2026-07-10-parallel-tool-call-execution.md: 830691b7596accd1c5746fc51e38a9022f09e501 -2026-07-10-parallel-tool-call-execution.zh.md: bd336919adbf9b96f64477c1e24964172cde6d92 +2026-07-10-parallel-tool-call-execution.md: 47a95e88c3d0d8bb9e3ddd820b1e63849effd914 +2026-07-10-parallel-tool-call-execution.zh.md: 024e5d4e4cdbf9d1d980bc67db5969fb34941752 diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md index 830691b759..47a95e88c3 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md @@ -60,7 +60,7 @@ Any shared state touched during execution must be concurrency-safe. This include `maxParallelToolCalls` is a positive AgentLoop deployment cap shared by every agent the factory creates. It defaults to `10`; `1` preserves serial execution. Exact fields and defaults live in the generated [configuration catalog](../../../../docs/config-catalog.md). -The shipped declarations are conservative. Web search, web fetch, and filesystem read opt in. Filesystem writes and edits, bash tools, subagent delegation, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. A subagent may share its parent's workspace or external resources, and the unary classifier cannot prove that sibling delegations have disjoint effects. Bash has no proven input-sensitive classifier and remains exclusive. +The shipped declarations are conservative. Web search, web fetch, filesystem read, the session-query trace/read tools, and subagent delegation opt in — delegation because a child works in its own session and its run never mutates the parent session, with sibling workspace coordination owned by the model ([parallel subagent Agent Note](2026-08-09-parallel-subagent-delegations.md)). Filesystem writes and edits, bash tools, the session-query search tools, workflow, user interaction, todo mutation, Code Mode, and Cordis mutation tools remain exclusive. Bash has no proven input-sensitive classifier and remains exclusive. Filesystem read relies on a narrow recorder exception: its synchronous observation updates may settle out of order, but write and edit re-check the observed version before mutation, so stale state only produces `FS_STALE_VERSION`. diff --git a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md index bd336919ad..024e5d4e4c 100644 --- a/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md +++ b/.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.zh.md @@ -60,7 +60,7 @@ Code Mode 仍不使用此调度器,因为模型只会发出一个原生 `run_c `maxParallelToolCalls` 是 AgentLoop 的正整数部署上限,由工厂创建的所有 agent(智能体)共享。默认值为 `10`;`1` 保持串行执行。字段和默认值的精确定义见生成的[配置目录](../../../../docs/config-catalog.md)。 -当前实现中的声明保持保守。Web 搜索、Web 获取和文件系统读取选择并行。文件系统写入与编辑、bash 工具、subagent 委派、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。subagent 可能共享父级的工作区或外部资源,而一元分类器无法证明并列委派的作用互不重叠。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。 +当前实现中的声明保持保守。Web 搜索、Web 获取、文件系统读取、会话查询的 trace/read 工具和 subagent 委派选择并行;委派之所以并行,是因为子 agent 在自己的会话中工作,其运行绝不变更父会话,并列委派间的工作区协调由模型负责([并行 subagent Agent Note](2026-08-09-parallel-subagent-delegations.md))。文件系统写入与编辑、bash 工具、会话查询的 search 工具、工作流、用户交互、todo 变更、Code Mode 以及 Cordis 变更工具仍按独占方式执行。Bash 没有已证明的输入敏感分类器,因此仍按独占方式执行。 文件系统读取依赖一个范围很窄的记录器例外:其同步观察更新可以不按顺序结算,但写入和编辑在变更前会重新检查已观察的版本,因此陈旧状态只会导致 `FS_STALE_VERSION`。 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml index 445f6548b3..9d13851a0d 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md -2026-07-20-dsh-cli-personal-config.md: f5207c5ffbd963b9b7c4a7166fa9f17a460707a9 -2026-07-20-dsh-cli-personal-config.zh.md: 24478a4b4fd5878032bf80f5b30ab9e2008da785 +2026-07-20-dsh-cli-personal-config.md: ed04725e92848bbab550a27ef2f4c021536f765e +2026-07-20-dsh-cli-personal-config.zh.md: cc97987f803f7fb513e94ce0ce079558f5e3dc75 diff --git a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md index f5207c5ffb..ed04725e92 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.md @@ -14,12 +14,12 @@ The entry modes and the personal file's name and location below are superseded b Two coupled pieces, aligned with the `apps/` assembly tier proposed by the `dsh web` PR (#443): -**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` is the product-assembly tier over `packages/*` libraries. One bin dispatches the default interactive TUI, `-p`/`--prompt` headless turns, and the `web` surface. The TUI boots `examples/tui-agent/cordis.yml` (or `--config`) with the invoking directory as the workspace. The committed `bin/dsh` launcher resolves the checkout through its own real path and runs the app with tsx's ESM hook; the [source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) owns that contract. `pnpm run demo:tui` runs the same entry. +**The `dsh` CLI (`apps/cli`, npm name `@deepseek-ai/dsh`).** `apps/*` is the product-assembly tier over `packages/*` libraries. One bin dispatches the default interactive TUI, `-p`/`--prompt` headless turns, and the `web` surface. The TUI boots `examples/tui-agent/cordis.yml` (or `--config`) with the invoking directory as the workspace. From a source checkout, the root `pnpm dsh` script builds the repository and runs the same entry with tsx's ESM hook; the [source-launch decision](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md) owns that contract. **Personal config (`dsh-app-boot`).** The personal overlay lives in the Harness home — `$DSH_HOME`, else `~/.dsh` — resolved by the shared [`resolveDshHome`](../architecture/2026-07-24-single-harness-home-resolver.md) (`@deepseek-ai/dsh-paths`), the same single root skills and AGENTS.md resolve against. The dsh TUI, Web, and headless surfaces consume its two optional files; the demo bins boot their committed trees verbatim: - `.env` — loaded after the invoking directory's `.env`; `process.loadEnvFile` never overrides, so precedence is ambient > project `.env` > personal `.env`. -- `config.yaml` — a top-level YAML array of `@cordisjs/plugin-include` `PatchOptions`, parsed with the include's own `!!js` dialect (`loadPersonalPatches`) and passed to `boot()`, which forwards it as the root include's `patches`. Patch semantics 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. +- `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. External packages are installed as [profile bundles](../simplification/2026-08-09-remove-repository-plugin.md); this personal layer configures the Loader rows those bundles contribute. - 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. @@ -40,7 +40,7 @@ The TUI and Web register the exact personal path through Cordis HMR after boot. ## Consequences -- `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. +- An installed `dsh` command can run from any directory, while source users invoke `pnpm dsh` from the checkout; both can apply personal providers, models, installed bundle entries, and other Loader entries with no checkout edit. The behavior was 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`](../../../../apps/cli/README.md#profiles) (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`. 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 24478a4b4f..cc97987f80 100644 --- a/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md +++ b/.agents/notes/implemented/feature/2026-07-20-dsh-cli-personal-config.zh.md @@ -14,12 +14,12 @@ Status: implemented 两个耦合的部分,与 `dsh web` PR(#443)提出的 `apps/` 装配层对齐: -**`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` CLI(命令行界面;`apps/cli`,npm 名 `@deepseek-ai/dsh`)。** `apps/*` 是位于 `packages/*` 库之上的产品组装层。一个 bin 负责分发默认交互式 TUI、`-p`/`--prompt` 无头轮次和 `web` 界面。TUI 以调用目录为 workspace,启动 `examples/tui-agent/cordis.yml`(或 `--config` 指定的配置)。在源码检出中,根目录的 `pnpm dsh` 脚本先构建仓库,再使用 tsx 的 ESM hook 运行同一入口;该约定由[源码启动决策](../architecture/2026-07-29-dsh-source-launch-tsx-esm.md)维护。 **个人配置(`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 静默不执行任何操作。[仓库插件集成](2026-07-30-config-only-repository-plugins.md)通过一个已交付配置项,使精确 GitHub 源列表成为纯配置选择。 +- `config.yaml`——顶层 YAML 数组,元素为 `@cordisjs/plugin-include` 的 `PatchOptions`,用 include 自己的 `!!js` 方言解析(`loadPersonalPatches`)并传给 `boot()`,由它作为根 include 的 `patches` 转发。补丁语义与交付的 surface overlay 一致:按 id 定位的补丁替换该配置项的整个 `config`,`insert` 追加配置项,未匹配的 id 静默不执行任何操作。外部包作为 [profile 组合包](../simplification/2026-08-09-remove-repository-plugin.md)安装;这个个人层负责配置这些组合包提供的 Loader 配置项。 - 文件缺失即无 overlay;文件存在但不可读、不可解析或非数组则在启动时抛出(配置错误响亮失败,绝不静默跳过)。 PTY 冒烟测试的启动器把 `$DSH_HOME` 隔离到每个测试自己的目录,与它已有的 `DSH_AGENTS_HOME` 隔离方式完全一致,开发者真实的个人 overlay 不可能泄漏进 fixture;只有 dsh CLI 读取个人配置,因此其他测试启动器无需改动。 @@ -40,7 +40,7 @@ TUI 和 Web 启动后通过 Cordis HMR(热模块替换)注册确切的个人 ## Consequences -- 在任意目录运行 `dsh`(以及 `pnpm run demo:tui`),无需修改 checkout,即可应用个人提供方、模型、仓库插件和其他 Loader 配置项;已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 +- 已安装的 `dsh` 命令可从任意目录运行,源码用户则从 checkout 调用 `pnpm dsh`;两者都无需修改 checkout 即可应用个人提供方、模型、已安装组合包的配置项和其他 Loader 配置项。该行为已针对个人 Anthropic 代理与 Opus 4.8 端到端验证,包括一次 bash 工具往返。 - 由于按 id 定位的补丁替换整个 `config`,个人覆盖必须复述它保留的基础字段,并可能随基础配置项形态变化而漂移;诊断手段是 loader 的「配置项未找到/名称不匹配」警告和 [`dsh --dump-config`](../../../../apps/cli/README.md#profiles)(打印这些补丁合成出的配置树)。 - 个人补丁只在被启动文件自身的树里解析 id,因此嵌套 include 的 overlay(Code Mode)不会被个性化;这些叶子的实际运行等价性暂缓。 - `dsh-app-boot` 依赖 `js-yaml`,并直接导入 include 的 `!!js` YAML 方言(`entryListSchema`);与 `apps/cli` 一样依赖 `@deepseek-ai/dsh-paths` 以获取 `resolveDshHome`。 diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml index 0308005515..dbfaaad95a 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md -2026-07-25-subagent-policy-inheritance.md: aeff83795eedead9c75de6bbb74c1da1945092ca -2026-07-25-subagent-policy-inheritance.zh.md: c26e6bf8b79c86855022c384673957fe04ff761d +2026-07-25-subagent-policy-inheritance.md: 34751a4e29e48c84d37425857b8b1b56c8d866eb +2026-07-25-subagent-policy-inheritance.zh.md: 5fa8edf04ed63da9b2e1b9a062ca2f649c8c96fb diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md index aeff83795e..34751a4e29 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md @@ -1,4 +1,4 @@ -# Agent Note: In-process subagent policy inheritance — the child starts under the parent's sandbox and approval overrides +# Agent Note: In-process subagent policy inheritance — the child starts under the parent's sandbox override Status: implemented @@ -6,11 +6,11 @@ English | [中文](2026-07-25-subagent-policy-inheritance.zh.md) ## Problem -Sandbox and approval overrides are per-session log folds. An in-process subagent gets a new session, so a spawn child once fell back to deployment defaults and a fork child saw only switches inside its completed-turn prefix. Delegation could therefore widen a parent that had switched to `read-only`, or turn a parent's unattended `'never'` approval stance back into prompting behavior. +Sandbox and approval overrides are per-session log folds. An in-process subagent gets a new session, so a spawn child once fell back to deployment defaults and a fork child saw only switches inside its completed-turn prefix. Delegation could therefore widen a parent that had switched to `read-only`. ## Decision -The shared in-process driver snapshots `sandboxPolicy.overrideOf(parent.session)` and `approval.overrideOf(parent.session)` before its first await. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. Both services are optional, and only explicit session overrides are copied, never deployment defaults or one-shot grants. +The delegation boundary snapshots `sandboxPolicy.overrideOf(parent.session)` before its first await, through the shared child-agent helpers (`captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides` in `dsh-subagent`), which the one-shot driver and the [continuable start](2026-08-10-continuable-subagent-policy-inheritance.md) both call. A later parent switch belongs to the parent's future; cancel-and-redelegate takes a new snapshot. The sandbox-policy service is optional, and only the explicit session override is copied, never deployment defaults or one-shot grants. The approval policy is not inherited: the same capture pins every child to `'never'` — the [approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md) supersedes this note's original approval-override inheritance. Each captured value becomes a source-tagged `sandbox/mode` or `approval/policy` event appended during the child factory's unpublished setup. The session constructor has already fixed `Session.firstLiveSeq` at the fork-prefix length, so the inherited facts follow fork history, reach telemetry when the child is announced, and leave `SessionHeader.seedLength` at the prefix length. Existing last-event-wins folds therefore make the delegation snapshot beat stale fork history and let a later child switch beat the snapshot. A grandchild folds its parent's logged state, so the rule composes without another inheritance mechanism. @@ -18,7 +18,7 @@ Ordinary session appends validate the inherited events before publication, and p ### What a blocked child experiences -A confined child gets the ordinary denial marker. No answerer currently owns an in-process child, so an escalation request fails closed and the child reports upward; a controller-owned parent may widen its own session and delegate again. An inherited `'never'` policy tells the child not to request escalation in its first system prompt. +A confined child gets the ordinary denial marker, and an escalation request is rejected deterministically by the child's pinned `'never'` policy; the `subagent:delegation` runtime-context statement tells the child to report the limitation instead of retrying, and a controller-owned parent may widen its own session and delegate again ([approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md)). ## Alternatives considered @@ -27,10 +27,10 @@ A confined child gets the ordinary denial marker. No answerer currently owns an - **A first-prompt listener** — rejected: it introduces listener ordering and a later timing boundary even though the creation transaction already permits log appends before publication. - **Copying deployment defaults** — rejected: defaults remain operator-owned and may change; an unswitched parent stamps nothing, so its child follows the current deployment. - **Live resolution walking `parentSession` at each call** — rejected: it breaks the "two sessions never see each other's state" isolation invariant, requires the parent session to stay loaded for the child's lifetime, and makes a mid-run parent switch retroactively change a running child. Snapshot-at-delegation is the semantic: the child keeps the policy it was handed; cancel-and-respawn picks up a tightening. -- **Forcing `'never'` or routing asks to the root controller** — rejected as inheritance behavior. A forced value forecloses a future child answerer; parent routing needs parent-chain ownership and the spawning `callId`, and remains deferred in [the approval-seam Agent Note](2026-07-06-approval-seam.md). +- **Forcing `'never'`** — originally rejected here as inheritance behavior because a forced value forecloses a future child answerer; that verdict is reversed by the [approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md), which owns the current rationale. Routing asks to the root controller needs parent-chain ownership and the spawning `callId`, and remains deferred in [the approval-seam Agent Note](2026-07-06-approval-seam.md). ## Consequences -- Spawn, fork, and nested in-process children retain a parent's explicit sandbox and approval overrides. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal. +- Spawn, fork, and nested in-process children retain a parent's explicit sandbox override and are pinned to `'never'` approvals. The focused suite proves real filesystem denial, stale-fork precedence, delegation-time capture, the live-event boundary, default omission, and context disposal. - The keyless headless snapshot is the assembled regression: only the parent is `read-only`, the deployment default is `workspace-write`, and the child's persisted event plus denied disk write both fail if capture is removed. -- Each delegation adds at most two log-only events. `dsh-subagent-inprocess` has optional peer types for the two policy services; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. +- Each delegation adds at most two log-only events. `dsh-subagent` owns the optional peer types for the two policy services — its shared helpers hold the `ctx.get` consumption; compositions without either service behave unchanged. Out-of-process children retain their own deployment policy, and a running child does not follow later parent switches. diff --git a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md index c26e6bf8b7..5fa8edf04e 100644 --- a/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md +++ b/.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.zh.md @@ -1,4 +1,4 @@ -# Agent Note: 进程内 subagent 策略继承——子 agent 在父级的沙箱与审批覆盖项下启动 +# Agent Note: 进程内 subagent 策略继承——子 agent 在父级的沙箱覆盖项下启动 Status: implemented @@ -6,11 +6,11 @@ Status: implemented ## 问题 -沙箱与审批覆盖项都是按会话的日志折叠。进程内 subagent 会获得一个新会话,因此 spawn 子 agent(智能体)过去会回退到部署默认值,fork 子 agent 则只能看到其已完成轮次前缀中的切换。因此,委派可能放宽已经切换到 `read-only` 的父级,或让父级无人值守的 `'never'` 审批立场重新变成会发起提示的行为。 +沙箱与审批覆盖项都是按会话的日志折叠。进程内 subagent 会获得一个新会话,因此 spawn 子 agent(智能体)过去会回退到部署默认值,fork 子 agent 则只能看到其已完成轮次前缀中的切换。因此,委派可能放宽已经切换到 `read-only` 的父级。 ## 决策 -共享的进程内驱动器在第一次 await 之前对 `sandboxPolicy.overrideOf(parent.session)` 和 `approval.overrideOf(parent.session)` 获取快照。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。这两个服务均为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。 +委派边界在第一次 await 之前,经由共享的子 agent 辅助函数(`dsh-subagent` 中的 `captureDelegatedPolicyOverrides`/`appendDelegatedPolicyOverrides`)对 `sandboxPolicy.overrideOf(parent.session)` 获取快照;一次性驱动器与[可继续启动](2026-08-10-continuable-subagent-policy-inheritance.md)都会调用这些辅助函数。父级后续的切换属于父级的未来;取消后重新委派会取得新快照。沙箱策略服务为可选,仅复制显式会话覆盖项,绝不复制部署默认值或一次性授权。审批策略不继承:同一次捕获会把每个子 agent 钉定为 `'never'`——[审批钉定决策](2026-08-10-subagent-approval-pinned-never.md)取代了本 note 原先的审批覆盖项继承。 每个捕获值都会成为子 agent 工厂在未发布设置阶段追加的一条带来源标记的 `sandbox/mode` 或 `approval/policy` 事件。会话构造函数已将 `Session.firstLiveSeq` 固定为 fork 前缀的长度,因此继承事实会排在 fork 历史之后,在子 agent 公布时进入遥测,同时让 `SessionHeader.seedLength` 保持为此前缀的长度。因此,既有的末事件胜出折叠会让委派快照压过陈旧的 fork 历史,并让子 agent 后续的切换压过该快照。孙代 agent 会折叠其父级已记录的状态,因此无需另一套继承机制即可组合此规则。 @@ -18,7 +18,7 @@ Status: implemented ### 被拦住的子 agent 会经历什么 -受限子 agent 会得到普通拒绝标记。目前没有应答器认领进程内子 agent,因此升级请求会以拒绝方式失败,由子 agent 向上汇报;由控制器持有的父 agent 可以放宽自己的会话后重新委派。继承的 `'never'` 策略会在第一份系统提示词中告知子 agent 不要请求升级。 +受限子 agent 会得到普通拒绝标记,升级请求则被子 agent 钉定的 `'never'` 策略确定性拒绝;`subagent:delegation` 运行时上下文声明告知子 agent 上报限制而不是重试,由控制器持有的父 agent 可以放宽自己的会话后重新委派([审批钉定决策](2026-08-10-subagent-approval-pinned-never.md))。 ## 考虑过的替代方案 @@ -27,10 +27,10 @@ Status: implemented - **首个提示词监听器**:不予采纳。尽管创建事务已经允许在发布前追加日志,它仍会引入监听器顺序与更晚的时序边界。 - **复制部署默认值**:不予采纳。默认值仍由运维人员拥有且可能变化;未切换的父级不会记录任何值,因此其子 agent 跟随当前部署。 - **每次调用时沿 `parentSession` 实时解析**:不予采纳。这会打破「两个会话永远看不到彼此状态」的隔离不变量,要求父会话在子 agent 的整个生命周期内保持加载,还会让父级在子 agent 运行途中做的切换追溯性地改变一个正在运行的子 agent。委派时快照才是本设计的语义:子 agent 保持它被交付时的策略;取消后重新 spawn 即可拿到收紧后的策略。 -- **强制使用 `'never'` 或把 ask 路由到根控制器**:不作为继承行为采纳。强制值会排除未来的子 agent 应答器;父级路由需要父链所有权与发起 spawn 的 `callId`,仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 所述延期。 +- **强制使用 `'never'`**:本 note 当初不作为继承行为采纳,理由是强制值会排除未来的子 agent 应答器;该结论已被[审批钉定决策](2026-08-10-subagent-approval-pinned-never.md)推翻,现行理由归其所有。把 ask 路由到根控制器需要父链所有权与发起 spawn 的 `callId`,仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 所述延期。 ## 后果 -- spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱与审批覆盖项。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。 +- spawn、fork 和嵌套的进程内子 agent 会保留父级显式的沙箱覆盖项,并被钉定为 `'never'` 审批。聚焦测试套件证明真实文件系统拒绝、陈旧 fork 优先级、委派时捕获、实时事件边界、默认值省略与上下文释放。 - 无密钥 headless 快照是组装后应用层面的回归测试:只有父级是 `read-only`,部署默认值是 `workspace-write`;若移除捕获,子 agent 的持久化事件与被拒的磁盘写入这两项检查都会失败。 -- 每次委派最多增加两条仅日志事件。`dsh-subagent-inprocess` 为两个策略服务提供可选 peer 类型;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 +- 每次委派最多增加两条仅日志事件。两个策略服务的可选 peer 类型由 `dsh-subagent` 拥有——其共享辅助函数持有 `ctx.get` 消费;未组合任一服务的组合保持原有行为。进程外子 agent 仍采用自身的部署策略,正在运行的子 agent 不跟随父级后续切换。 diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index b73a552168..af4c04bc0e 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md -2026-07-27-trajectory-inspection-ledger.md: a905e65942365c17b7513028b275288c82428221 -2026-07-27-trajectory-inspection-ledger.zh.md: a8dcfa97a89f3adc6ab540f3d6cc5020cbefb53f +2026-07-27-trajectory-inspection-ledger.md: 74ed1f8ec6f6efcbf77e9caec7e254cb114efbd9 +2026-07-27-trajectory-inspection-ledger.zh.md: c6bb315b72b8a7274ca0e1b245cb2c5583328c8a diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md index a905e65942..74ed1f8ec6 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.md @@ -12,20 +12,20 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested **Render a compact, turn-aware event ledger with a local record inspector, using the existing DeepSeek design system.** -- The ledger keeps session events in sequence within rewind-delimited branches. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests. +- The ledger keeps materialized business records in Session Event order within the loaded window. Turn boundaries use a slightly heavier rule, the raw Turn id, and a continuous left rail; Request boundaries appear as small points integrated into that structure and use one chronological numbering space across ordinary and compaction requests. - Event kind and content form the two stable columns. Role tags align toward the content, nested subtools receive a small indentation, and CSS truncation preserves the available preview width. Token usage and duration stay in the inspector. - Product prose uses the existing sans stack. Turn ids, token counts, durations, tool calls, raw payloads, and other machine data use the existing code stack. - Existing theme tokens own both light and dark rendering. Neutral borders and surfaces form the structure; distinct low-emphasis role hues support scanning without carrying success or failure meaning, while business blue identifies selection, links, and focus. -- The client runtime exposes a read-only history source independent from Session and SessionManager. Each activated source owns its raw entries, paging, live gap repair, and reconnect rebuild; the ordinary conversation snapshot remains the folded Chat projection. Trajectory opens the source's tail while mounted and requests one older page when the user reaches the loaded range's top, then lazily derives event order, context lineage, schema index, and Requests instead of imposing those structures on every conversation consumer. +- Session owns one contiguous Event window, paging state, live gap repair, and reconnect rebuild. Chat and Trajectory register separate business Definitions against the shared `ConversationNodeAssembler`; Trajectory reads its target snapshot from `Session.views` and requests one older Session page when the user reaches the loaded range's top. The [Trajectory Context assembly decision](../architecture/2026-08-11-trajectory-conversation-context-assembly.md) owns its exact-ID Definitions, stage Builder, and complexity bounds. - Ordinary generation and compaction calls form one chronological Request projection, distinguished by purpose rather than separate collections. Effective prompt state and its change ride the Request that introduced them; compaction and prompt changes are not independent inspection entities. Request numbering and cumulative usage cover the loaded history window and expand as older pages arrive. - Call schemas come from the active recorded Request header. Keyless snapshot fixtures deliberately replace that catalog with the non-array `{{tools}}` token, which the durable inspection boundary treats as unavailable instead of attempting to project or fabricate schemas. - Selecting a record or Request opens an inspector inside Trajectory. Tabs and Summary sections follow the selected entity: Markdown messages expose rendered content, source fields, provider/model fields, and hierarchy views; tools add JSON payload/result and schema views; Requests add options, usage, timing, and result navigation. Scrollable Summary regions keep their scrollbar thumbs transparent until hover or `focus-within`, while retaining the scrollbar reservation and scroll behavior. Images render as media rather than serialized data. - Turn folding removes all rows after its first record and replaces them with a compact step/tool-call count; Assistant folding applies the same interaction to its tool-call descendants. Global controls fold or expand both levels. -- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older page is pending. The raw window base sequence detects a prepend even when a page adds no surface-visible node. -- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. +- A long ledger initially positions the loaded tail at the bottom and mounts only the viewport's row window plus bounded overscan. Request-only separators join the next measurable virtual item, with a terminal separator retaining its own fixed clearance, so the virtualizer never owns a zero-height item. Semantic DOM-safe row keys and ARIA indexes expose identity independently from mount position. A tail with known older history virtualizes immediately even when its loaded projection is below the ordinary row threshold. Stable-key virtualizer anchoring preserves the visible item across prepends and appends; the manual scroll-height fallback applies only when completing pagination disables virtualization. Selection, timeline focus, folding, search, and bottom following address records by stable event or tool-call identity rather than requiring their DOM rows to exist. An explicit loading row covers records until initial positioning finishes and while an older Session page is pending. +- The separate Waterfall tab is removed. A fixed Overview above the ledger projects every loaded record with known `startedAt` onto three semantic timing lanes using its own duration. While an older prefix remains unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control covers the truncated edge and loads one earlier page without assigning unknown history a fabricated duration; hovering that control suppresses the ordinary timeline cursor. Finalized Assistant spans divide the recorded interval at the first non-empty token delta, so distinct TTFT and decoding colors retain their actual ratio; incomplete timing falls back to one Assistant color. Hovering for 500 ms exposes exact start/end, total duration, TTFT, and decoding time without relying on the browser's native tooltip delay. Dragging left or right commits an inclusive interval filter: any record whose active interval overlaps either boundary remains visible, records without known timing leave the focused ledger, and clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the interval selection; dragging instead pans an already zoomed viewport without mutating it. The Overview keeps the full time domain while focused so the selection can be resized or cleared without losing orientation. - Live history updates retain the ledger's bottom position only while the user is already following its tail. Scrolling upward clears that follow state, so streamed chunks and newly appended records do not interrupt inspection of earlier rows. Tail following and virtualizer measurement react to row keys and heights rather than content identity, so text-only stream frames neither discard the measurement cache nor repeat a DOM scroll write. -- Token streaming reuses the finalized history inspection, layout, Request numbering, Overview projection, and search results. A frame appends only the current partial Assistant cells and searches that partial when a query is active; text and reasoning deltas do not re-fold or rescan the loaded prefix, while message completion, tool lifecycle, compaction, rewrites, and other structural events rebuild the affected projections. Before those rebuilds, the inspection ledger drops completed-step token payloads that no projection reads while retaining the first visible token for timing, every usage chunk for accounting, and every chunk from unfinished or interrupted steps; the independent history source retains the raw entries. -- History folding rebases only the loaded surface events into a compact contiguous input for the canonical surface manager, then maps its nodes back to absolute session sequences. Structural events therefore retain canonical replacement validation without replaying token chunks or materializing synthetic events for unloaded sequences. +- Token streaming updates only the matching Trajectory Assistant Context, while publication is coalesced to at most once per animation frame. The target snapshot preserves the existing stage, layout, Request numbering, Overview, and search inputs; completed Assistant State retains assembled blocks, timing, and usage rather than every raw chunk payload, while Session keeps the raw Event window. +- Each Trajectory Definition extracts a stable ID from the current Event, and the shared Assembler replays only Contexts affected by matching, Location, or Reader changes. Older Session pages prepend into the same engine window; the Trajectory target builder converts its materialized Nodes into the existing stage-oriented snapshot consumed by the ledger. - Trajectory opts into a conversation-owned composer overlay through `data-conversation-composer-overlay`. `ConversationRoot` positions the composer seat and publishes its live height; Trajectory keeps the ledger at full height and reserves that height plus 16 px inside its vertical table and inspector scrollers. Those panes adapt to the available width instead of exposing horizontal scrollbars beneath the overlay. - This local inspector remains independent from the conversation-wide Chat details column. At narrow widths it overlays the ledger and remains dismissible by keyboard or pointer. @@ -53,4 +53,4 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested ## Consequences -Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions remain inline with their surrounding history, while a rewind begins a successor branch that inherits only the retained prefix. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provider/model and source fields, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport and projection work, virtualization bounds mounted row elements, incremental partial projection removes loaded-history length from ordinary token-frame work, and completed-step chunk compaction makes structural rebuilds proportional to inspection-relevant entries rather than the raw token count. Focused component tests pin tail-first paging, prepend anchoring and identity retention, the virtual window, tail following, content-only streaming without repeated scroll writes, streaming structural sharing, high-sequence window folding, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics. A real-browser long-ledger contract pins stable prepend geometry, bounded mounting, top/middle/bottom reachability, and bounded scroll writes across a paced stream; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. +Trajectory shows more useful records per viewport while retaining Turn and Request orientation. Context rewrites and compactions appear as the current materialized business records in sequence with surrounding history. The floating composer leaves the ledger visible to the viewport edge without covering its final rows or hiding horizontal controls. The main ledger omits token usage and duration so content receives the available width; the local inspector exposes those facts together with full payloads, provider/model and source fields, schemas, and request timing. The Overview uses recorded start/duration and token-boundary facts without fabricating live elapsed time, and its inclusive focus behavior matches the interaction users already know from Chrome DevTools Network. Tail-first paging bounds initial transport work, virtualization bounds mounted row elements, exact-ID dispatch avoids re-folding unrelated business Contexts, and animation-frame publication caps streaming snapshot frequency. The retained stage-oriented target builder may still perform work proportional to the loaded materialized Nodes for a publication; this migration does not add a stronger Trajectory-specific complexity guarantee. Focused component tests pin tail-first paging, prepend anchoring and identity retention, the virtual window, tail following, content-only streaming without repeated scroll writes, timing projection, delayed detail disclosure, folding, record and interval selection, entity-specific tabs, and running/error semantics. A real-browser long-ledger contract pins stable prepend geometry, bounded mounting, top/middle/bottom reachability, and bounded scroll writes across a paced stream; the assembled Web snapshot pins the ledger, Overview timing details, composer overlay geometry, and inspector through the real client composition. diff --git a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md index a8dcfa97a8..c6bb315b72 100644 --- a/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md +++ b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.zh.md @@ -12,20 +12,20 @@ Status: implemented **使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** -- 记录表在以 `rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。 +- 记录表在已加载窗口内按 Session Event 顺序展示物化后的业务记录。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。 - 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 - 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 - 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 -- 客户端运行时提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 在挂载期间打开该数据源的尾部,当用户到达已加载范围顶部时请求一页更早的历史,再按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费方承担这些结构。 +- Session 统一拥有一份连续 Event 窗口、分页状态、实时缺口修复与重连重建。Chat 与 Trajectory 针对共享的 `ConversationNodeAssembler` 分别注册业务 Definition;Trajectory 从 `Session.views` 读取自己的 target snapshot,并在用户到达已加载范围顶部时请求一页更早的 Session 历史。[Trajectory Context 组装决策](../architecture/2026-08-11-trajectory-conversation-context-assembly.md)负责其精确 ID Definition、stage Builder 与复杂度上界。 - 普通生成调用与压缩调用形成一条按时间排序的请求投影,以用途区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展。 - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 选择记录或请求后,Trajectory 内部会打开检查器,其标签页和概述区域随实体类型变化:Markdown 消息提供渲染内容、来源字段、提供方/模型字段和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。 - 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。 -- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。原始窗口的基准序号即使在一页未增加任何 surface 可见节点时,也能检测到这次向前补页。 -- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早 Session 页面仍在等待时,明确的加载行会遮住真实记录。 +- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 - 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。 -- token 流式输出会复用已完成历史的检查结果、布局、请求编号、Overview 投影和搜索结果。每个帧只追加当前未完成助手的单元格,并在查询处于激活状态时搜索这部分内容;文本与推理(reasoning)增量不会重新折叠或扫描已加载前缀,而消息完成、工具生命周期、压缩、`rewrite` 及其他结构事件会重建受影响的投影。在这些投影重建前,检查记录表会丢弃已完成步骤中没有任何投影读取的 token 载荷,但会保留首个可见 token 用于计时、保留所有用量分片用于核算,并保留未完成或中断步骤的所有分片;独立历史数据源仍保留原始条目。 -- 历史折叠只把已加载的 surface 事件重新编号为紧凑连续的输入并交给规范 surface manager,再将其节点映射回会话绝对序号。因此,结构事件会保留规范的替换校验,而无需重放 token 分片,也不会为未加载的序号实体化合成事件。 +- token 流式输出只更新命中的 Trajectory Assistant Context,发布则合并为每个 animation frame 最多一次。target snapshot 继续提供既有 stage、layout、请求编号、Overview 与搜索输入;已完成的 Assistant State 只保留组装后的 blocks、计时与 usage,不保留每条原始 chunk payload,而 Session 继续保存原始 Event 窗口。 +- 每个 Trajectory Definition 都从当前 Event 提取稳定 ID,共享 Assembler 只 replay 因 Match、Location 或 Reader 变化而受影响的 Context。更早 Session 页面 prepend 到同一个引擎窗口;Trajectory target builder 再把已物化 Node 转换为记录表继续消费的 stage-oriented snapshot。 - Trajectory 通过 `data-conversation-composer-overlay` 启用由会话持有的 composer 浮层模式。`ConversationRoot` 负责定位 composer seat 并发布其实时高度;Trajectory 让记录表保持全高,并在记录表与检查器的纵向滚动容器内预留该高度加 16 px。这两个窗格会根据可用宽度自适应,而不会在浮层下方暴露横向滚动条。 - 此局部检查器与会话级 Chat 详情栏相互独立。在窄屏下,检查器会覆盖记录表,并且仍可通过键盘或指针关闭。 @@ -53,4 +53,4 @@ Status: implemented ## 后果 -轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、提供方/模型字段、来源字段、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输和投影工作量,虚拟化限制已挂载的行元素数量,未完成部分的增量投影让普通 token 帧的工作量不再随已加载历史长度增长,而已完成步骤的分片压缩则让结构重建的工作量与检查所需条目数量成正比,而非与原始 token 数量成正比。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、流式输出的结构共享、高序号窗口折叠、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表约定锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 +轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩会作为当前物化的业务记录,按顺序出现在周边历史中。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、提供方/模型字段、来源字段、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输工作,虚拟化限制已挂载的行元素数量,精确 ID 分发避免重新 fold 无关业务 Context,animation-frame 发布则限制流式 snapshot 频率。保留的 stage-oriented target builder 在一次发布中仍可能执行与已加载物化 Node 数量成比例的工作;本次迁移不额外承诺更强的 Trajectory 专属复杂度。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表约定锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 Web 快照则通过真实客户端组合锁定记录表、Overview 计时详情、composer 浮层几何形状与检查器。 diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml index 809e37044f..e0ba016659 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-28-feedback-command.md -2026-07-28-feedback-command.md: 3edb29283c289d6d006891a4c19087b01fa8166f -2026-07-28-feedback-command.zh.md: c2513d2570474cbbaf8d94f87603d8ce10d40c14 +2026-07-28-feedback-command.md: d3b2774e41a82f6edb4303280f813ddbed75ebd1 +2026-07-28-feedback-command.zh.md: 3eeef92f2ed39c9546f013f217dd7f851d30c78c diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md index 3edb29283c..d3b2774e41 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.md @@ -18,7 +18,7 @@ The package declares the log-only `feedback/record { text }` session event and e `dsh-commands` still writes its `command/run` / `command/done` lifecycle pair around `/feedback`, but this command sets `recordInput: false`. Its `command/run` therefore carries the command identity and source without `args`; the feedback text exists only in `feedback/record`, while `command/done` carries the acknowledgement outcome. All three records are log-only and non-surface. Their appends enter persistence's ordinary bounded write path; nothing forces a flush, so acknowledgement reports that the feedback is in the log rather than already on disk. -Capture remains inert for the running agent and model. The optional OTel telemetry package later adds one infrastructure consumer: it observes `feedback/record` as a release trigger in `FEEDBACK_ONLY` mode and as the local-only warning trigger in `DISABLED` mode, without changing the feedback event or command path. See [Feedback-gated session telemetry](2026-08-05-feedback-gated-session-telemetry.md). +Capture remains inert for the running agent and model. The optional OTel telemetry package later adds one infrastructure consumer: it observes `feedback/record` as a release trigger in `FEEDBACK_ONLY` mode and as the local-only warning trigger in `DISABLED` mode, without changing the feedback event or command path. See [Feedback-gated session telemetry](2026-08-05-feedback-gated-session-telemetry.md) and the [acknowledgement sharing disclosure](2026-08-07-feedback-acknowledgement-sharing-disclosure.md). ### Why feedback owns an event diff --git a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md index c2513d2570..3eeef92f2e 100644 --- a/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md +++ b/.agents/notes/implemented/feature/2026-07-28-feedback-command.zh.md @@ -18,7 +18,7 @@ Status: implemented `dsh-commands` 仍会围绕 `/feedback` 写入 `command/run` / `command/done` 生命周期配对,但该命令设置了 `recordInput: false`。因此,它的 `command/run` 携带命令标识与来源,但不携带 `args`;反馈文本只存在于 `feedback/record` 中,而 `command/done` 携带确认结果。三个记录都仅写入日志且非 surface。它们的追加会进入持久化的常规有界写入路径;没有任何环节强制 flush,因此确认文本报告的是反馈已进入日志,而非已经落盘。 -采集对正在运行的 agent(智能体)与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为仅限本地的警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)。 +采集对正在运行的 agent(智能体)与模型仍不产生后续动作。可选的 OTel 遥测包后续增加了一个基础设施消费方:它在 `FEEDBACK_ONLY` 模式下将 `feedback/record` 作为释放触发器,在 `DISABLED` 模式下将其作为仅限本地的警告触发器,且不改变反馈事件或命令路径。见[反馈门控的会话遥测](2026-08-05-feedback-gated-session-telemetry.md)与[确认文本中的共享披露](2026-08-07-feedback-acknowledgement-sharing-disclosure.md)。 ### 为何反馈拥有自己的事件 diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml index 42a8e3e6bd..8753c066cf 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md -2026-07-29-persistent-bash-str-replace-editor.md: c4750e30370bfd253064c39cb1adc0f5b2baa60d -2026-07-29-persistent-bash-str-replace-editor.zh.md: 83159d9792fd9fadaaa342cc289300b35da34e4a +2026-07-29-persistent-bash-str-replace-editor.md: 2c077a08e6027245779a0db364c83d17a9c74fce +2026-07-29-persistent-bash-str-replace-editor.zh.md: f642f2100cbc40ddf688400c5e6124ca9a6ff72d diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md index c4750e3037..2c077a08e6 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.md @@ -18,7 +18,7 @@ Some deployments need a one-call Bash schema whose shell state survives across m Both plugins are included in the Python runtime closure. The persistent Bash closure also includes the PTY service/local backend and the sandbox services required by that backend. Because `node-pty` executes a native `spawn-helper` on macOS, each packaged macOS runtime executable ships with a `-spawn-helper` sibling; Linux uses `forkpty` directly. A pinned `node-pty` patch checks `DSH_NODE_PTY_SPAWN_HELPER` first, so it remains a true override for a current external consumer that supplies a non-sibling helper. When the override is unset, the patch resolves the packaged executable sibling if present and otherwise preserves upstream lookup in ordinary Node runs. The macOS builders fail before publication when the helper is absent or not executable. -The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay composes both plugins over the ordinary Web surface for the Claude SWE-compatible RL contract. It pins native tool mode and makes the complete system prompt `DSH_SYSTEM_PROMPT` when set or `You are a helpful software engineer assistant.` otherwise, with no harness identity, source-checkout section, Web orientation, Workspace instructions, or tool-mode guidance. It disables every other model-facing consumer, so the model receives exactly the persistent `bash` and `str_replace_editor` schemas, while the Web host, browser, Workspace, persistence, sandbox, and permission stack remains in place. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. +The shipped [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) composes both plugins for the Claude SWE-compatible RL contract. Its entry-local PTY realm carries the registry, local backend, and persistent Bash tool; the editor registers beside that realm against the host filesystem. The preset fixes the complete system prompt, follows the deployment tool-presentation mode, omits every other model-facing consumer, and leaves browser, Workspace, persistence, sandbox, and permission services on the shared Web host. The local PTY backend resolves the effective session sandbox mode when it creates the shell. While that owner has an open shell or a spawn in progress, a different permission mode is rejected before its session event commits; the editor continues through the Web filesystem sandbox. The [minimal-preset decision](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md) owns this composition boundary. ## Alternatives considered @@ -32,4 +32,4 @@ The shipped [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis. ## Consequences -Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. The Core Web profile retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. +Profiles can reproduce an external agent by configuring persona and descriptions while the underlying packages remain general. Persistent Bash requires an owning Agent and real PTY backend. Shell exit, timeout, or cancellation loses state. The editor delegates security and mutation policy to the mounted filesystem stack. A minimal Web agent retains Web permissions but must close its persistent shell before changing modes. Runtime-wheel consumers still need no Node installation; Linux wheels contain one executable, while macOS wheels also contain its private native helper. diff --git a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md index 83159d9792..f642f2100c 100644 --- a/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md +++ b/.agents/notes/implemented/feature/2026-07-29-persistent-bash-str-replace-editor.zh.md @@ -18,7 +18,7 @@ Status: implemented 两个插件都进入 Python runtime 闭包。持久 Bash 的闭包还包含 PTY 服务/本地后端,以及该后端要求的沙箱服务。由于 `node-pty` 在 macOS 上会执行原生 `spawn-helper`,每个打包后的 macOS 运行时可执行文件都会携带一个 `-spawn-helper` 伴随文件;Linux 直接使用 `forkpty`。固定版本的 `node-pty` 补丁会先检查 `DSH_NODE_PTY_SPAWN_HELPER`,因此对当前提供非伴随 helper 的外部消费方而言,该变量仍是真正的覆盖项。未设置该覆盖时,补丁会在打包可执行文件的伴随文件存在时解析它,否则在普通 Node 运行中保留上游查找方式。若 helper 缺失或不可执行,macOS 构建器会在发布前失败。 -已交付的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) overlay 会在常规 Web 界面之上组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。它固定使用原生工具模式;完整的系统提示词在设置 `DSH_SYSTEM_PROMPT` 时采用其值,否则采用 `You are a helpful software engineer assistant.`,且不包含 harness 身份、源码 checkout 提示词段、Web 界面定位、Workspace 指令或工具模式指引。它会禁用其他所有面向模型的消费方,使模型恰好只收到持久 `bash` 和 `str_replace_editor` 两个 schema,同时保留 Web 宿主、浏览器、Workspace、持久化、沙箱与权限栈。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。 +随附的 [`minimal` agent preset](../../../../apps/cli/config/agent-presets/minimal/agent.cordis.yml) 会组合这两个插件,以满足与 Claude SWE 兼容的 RL 约定。其 entry 本地 PTY realm 持有注册表、本地后端和持久 Bash 工具;编辑器在该 realm 旁注册,并使用宿主文件系统。preset 会固定完整系统提示词、跟随部署的工具呈现模式,省略其他所有面向模型的消费方,并将浏览器、Workspace、持久化、沙箱与权限服务留在共享 Web 宿主上。本地 PTY 后端会在创建 shell 时解析会话的有效沙箱模式。只要该所有者仍有打开的 shell 或仍在进行中的 spawn,另一种权限模式就会在对应的会话事件提交前遭到拒绝;编辑器则继续经由 Web 文件系统沙箱运行。这一组合边界由 [minimal-preset 决策](../bug-fix/2026-08-10-minimal-preset-owns-rl-composition.md)负责说明。 ## 考虑过的替代方案 @@ -32,4 +32,4 @@ Status: implemented ## 后果 -Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。Core Web profile 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 +Profile 可以通过配置 persona 和描述复现外部 Agent,而底层包保持通用。持久 Bash 需要拥有它的 Agent 与真实 PTY 后端;shell 退出、超时或取消会丢失状态。编辑器把安全与变更策略委托给挂载的文件系统栈。minimal Web agent 保留 Web 权限,但必须先关闭持久 shell 才能更改权限模式。运行时 wheel 包的消费方仍无需安装 Node;Linux wheel 包包含一个可执行文件,macOS wheel 包还包含其私有原生 helper。 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 deleted file mode 100644 index 35327a30e0..0000000000 --- a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md +++ /dev/null @@ -1,50 +0,0 @@ -# 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 trusted repository code still needs an exact-source, transactional lifecycle owned by the [repository package format](../architecture/2026-08-08-trusted-repository-package-code.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, installs its dependencies, runs its package-authored `prepack`, and atomically publishes the exact specifier. The selected package's direct development dependency on `@deepseek-ai/dsh-repository-plugin` supplies `dsh-plugin-prepare` through package-local `node_modules/.bin`; the lifecycle invokes it after any package-owned build. The DSH host imports the generated `dsh-plugin.mjs` wrapper and mounts it as a child fiber; that wrapper composes static skill and MCP owners plus an explicit trusted Cordis entry when declared. - -## 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. Repository MCP servers use strict startup, so an initial connection, discovery, or tool-registration failure rejects the candidate and becomes a config-update failure; non-strict standalone MCP clients retain their contained successful-Plugin/no-tools behavior. - -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, dependencies, the explicit `dsh.entry`, and spawned MCP servers from that repository 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 prepared wrapper validates composition boundaries and lifecycle state; it does not make repository code safe to run when the source is untrusted. - -## 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. -- Skills and common MCP definitions retain portable static adapters, while an explicit `dsh.entry` can contribute DSH-native Cordis behavior. Format-specific 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 deleted file mode 100644 index 5755045560..0000000000 --- a/.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.zh.md +++ /dev/null @@ -1,50 +0,0 @@ -# Agent Note: 仅凭配置为独立 dsh 接入仓库插件 - -Status: implemented - -[English](2026-07-30-config-only-repository-plugins.md) | 中文 - -## 问题 - -独立 `dsh` 用户没有开发者自有的 SDK 项目,无法由其 `package.json`、lockfile 和 `cordis.yml` 承载外部插件依赖。若要求运行安装命令或维护另一份状态文件,「使用这个仓库」就会变成多步骤流程;受信任的 repository 代码仍需要由[repository 包格式](../architecture/2026-08-08-trusted-repository-package-code.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 选择已配置的 repository 子包,安装其依赖,运行包所定义的 `prepack`,并原子发布该精确说明符。所选包对 `@deepseek-ai/dsh-repository-plugin` 的直接开发依赖通过包内 `node_modules/.bin` 提供 `dsh-plugin-prepare`;该生命周期会在任何包自有构建完成后调用它。DSH 宿主会导入生成的 `dsh-plugin.mjs` 包装层并将其挂载为子 fiber;该包装层组合静态 skill(技能)与 MCP 所有者,并在声明时组合显式的受信任 Cordis 入口。 - -## 实时更新与失败 - -`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)` 事件;观察者失败不会中断刷新处理。Repository MCP 服务器采用严格启动,因此初始连接、发现或工具注册失败会拒绝候选配置,并构成配置更新失败;非严格的独立 MCP 客户端仍保留其所收束的「插件成功加载但无工具」行为。 - -相同说明符会永久复用同一个缓存版本。HMR 监视配置,而非已缓存的仓库代码;用户必须改变 ref、路径或源列表,才能选择另一个版本。 - -## 信任边界 - -配置仓库即授权该仓库中的包管理器生命周期代码、依赖、显式 `dsh.entry` 和 spawn 的 MCP server 以用户的文件系统权限运行。pnpm 子进程会移除名称中含有 `KEY`、`PASSWORD`、`SECRET` 或 `TOKEN` 的环境变量,但这只会减少凭据暴露,并非沙箱。已准备的包装层会校验组合边界和生命周期状态;当来源不受信任时,它无法让 repository 代码变得可安全运行。 - -## 考虑过的替代方案 - -**要求声明 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 定义保留可移植静态适配器,而显式 `dsh.entry` 可以贡献 DSH 原生 Cordis 行为。格式专用的兼容 shim、带 OAuth 的 MCP 定义和插件市场仍有意不提供。 - -## 测试 - -仓库包测试固定源规范化、默认和嵌套 `.dsh-plugin` 路径、缓存根解析、重复项拒绝、已准备包装层加载及资源释放。App-boot 测试通过真实 HMR/Include/Loader 路径驱动确切路径的新增、两类失败、恢复、移除、失败事件及生成补丁保留。一个无密钥 PTY 冒烟测试仅通过个人配置启动已交付的 `dsh` 组合,并从预置的不可变缓存版本中调用一个 skill。 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml index 151d74e3dc..41756caeb4 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md -2026-07-31-even-out-shipped-tool-rosters.md: 0195620055da5e570d2f54792d950a88bab8d652 -2026-07-31-even-out-shipped-tool-rosters.zh.md: ab6982e33c4a0a25cbc2fde386456840ce99d9c5 +2026-07-31-even-out-shipped-tool-rosters.md: 7647506e5d9c39d64b686ab18923f9681a48cd87 +2026-07-31-even-out-shipped-tool-rosters.zh.md: f04a59c9f00455b00f70975ad9b6bd4defd3d847 diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md index 0195620055..7647506e5d 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.md @@ -12,11 +12,11 @@ The result was a user-visible difference nobody had decided: the same model, ask ## Decision -The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster: twenty-two tools on every host — the twenty shared rows plus `glob` and `grep`, which are fixed members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). `tool-session-query` joined and then left again — the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md) keeps the model-facing consumer opt-in — while the rest of this roster stands. +The rows that are not surface-specific move into [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml), and three more join them: `tool-session-query`, `tool-str-replace-editor`, and `repeat-tool-guard`. Web search moves there too; its [deployment decision](2026-07-31-web-default-search.md) owns the security boundary while the shared base owns its surface-neutral mount. Both surfaces assemble the same roster, including fixed `glob` and `grep` members because `dsh-tool-fs-search` spawns the [packaged ripgrep binary](../architecture/2026-08-01-packaged-ripgrep-search.md). Two later decisions narrow that roster: the [session-search decision](2026-08-02-session-search-not-shipped-default.md) keeps `tool-session-query` opt-in, and the [single-editor decision](../simplification/2026-08-10-default-presets-single-editor.md) keeps `tool-str-replace-editor` out of the general-purpose presets while retaining it in `minimal`. Two rows stay surface-specific. `tmux-context` is TUI-only because a browser surface has no terminal multiplexer to describe. `session-reference` is TUI-only because it drives the shared session-query index from the launcher's process-local path, and the browser sidebar reconciles that index on its own first search. -**This roster decision added only at the time.** No tool row was removed from either surface when it landed, and a catalog comparison found additions and nothing else. One of those additions, `tool-session-query`, was subsequently removed by the [session-search-not-shipped-default decision](2026-08-02-session-search-not-shipped-default.md). The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md). +**This roster decision added only at the time.** No tool row was removed from either surface when it landed, and a catalog comparison found additions and nothing else. The later session-search and single-editor decisions own their respective default-roster exceptions. The shared executors, sandbox composition, and access default are owned independently by the [workspace-write default decision](2026-07-31-workspace-write-surface-default.md). ### What stays unmounted, and why diff --git a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md index ab6982e33c..f04a59c9f0 100644 --- a/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md +++ b/.agents/notes/implemented/feature/2026-07-31-even-out-shipped-tool-rosters.zh.md @@ -12,11 +12,11 @@ Status: implemented ## 决策 -那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单:每台宿主上都有二十二个工具——二十个共享行加上 `glob` 和 `grep`,它们成为固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。`tool-session-query` 加入后又退出了——[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)让面向模型的消费方保持需显式启用——而这份清单的其余部分保持不变。 +那些并非 surface 专属的行移入 [`base.cordis.yml`](../../../../packages/bundle/base/cordis.patch.yml),另有三行加入:`tool-session-query`、`tool-str-replace-editor` 和 `repeat-tool-guard`。Web 搜索也一并移入;其[部署决策](2026-07-31-web-default-search.md)负责安全边界,共享 base 则负责与 surface 无关的挂载。两个 surface 组装同一份清单,其中 `glob` 和 `grep` 是固定成员,因为 `dsh-tool-fs-search` 直接 spawn [打包的 ripgrep 二进制](../architecture/2026-08-01-packaged-ripgrep-search.md)。之后有两项决策收窄这份清单:[session-search 决策](2026-08-02-session-search-not-shipped-default.md)让 `tool-session-query` 保持需显式启用,[单一编辑器决策](../simplification/2026-08-10-default-presets-single-editor.md)让通用 preset 不提供 `tool-str-replace-editor`,但在 `minimal` 中保留它。 有两行仍是 surface 专属。`tmux-context` 只在 TUI,因为浏览器 surface 没有终端复用器可描述。`session-reference` 只在 TUI,因为它以 launcher 的进程本地路径驱动共享的 session-query 索引,而浏览器侧边栏会在自己的首次搜索里重建该索引。 -**本次工具清单决策当时只做加法。** 落地时两个 surface 均未移除任何工具行,目录对比只发现了新增,别无其他。这些新增中的一项 `tool-session-query` 随后被[session-search-not-shipped-default 决策](2026-08-02-session-search-not-shipped-default.md)移除。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。 +**本次工具清单决策当时只做加法。** 落地时两个 surface 均未移除任何工具行,目录对比只发现了新增,别无其他。后续的 session-search 与单一编辑器决策分别负责对应的默认清单例外。共享执行器、沙箱组合与访问默认值独立归属[workspace-write 默认值决策](2026-07-31-workspace-write-surface-default.md)。 ### 什么保持不挂,以及为什么 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml index 49b713f534..24219d9ff0 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md -2026-08-01-windows-pwsh-default.md: f0da86e52bcdd53a10b60164d7cc12261cfc5c49 -2026-08-01-windows-pwsh-default.zh.md: 41a6429eab8f86a8960ac4aa372aeacfda4661c4 +2026-08-01-windows-pwsh-default.md: 4e681b32088954d870df86898e26fe2cae669f14 +2026-08-01-windows-pwsh-default.zh.md: a9d600f8a8e47db49c3733f33091e667e341c6a7 diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md index f0da86e52b..4e681b3208 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.md @@ -12,9 +12,9 @@ The harness's shipped execution profile is bash-first on every platform. Windows Windows hosts booting a shipped profile (`dsh web`, `dsh --profile headless`, one-shot tasks) get the PowerShell stack by default; POSIX hosts are unchanged. -- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`: it disables `bash-sandbox`/`tool-bash` (the POSIX-only executor and its dialect tool) and inserts `pwsh-local`/`tool-pwsh`. Windows has no OS sandbox runner (landlock/bwrap/seatbelt are POSIX-only), so the layer drops the sandbox stack entirely — `sandbox`, `sandbox-policy`, and `fs-sandbox` are disabled and the unconfined `dsh-fs-local` provides `ctx.fs` — and degrades to danger-full-access: `permission`/`ui-permission` leave the roster (dsh-permission requires a confining executor — presets bundle a sandbox mode the unconfined executor cannot honor; see its constructor guard — and the client knob would advertise a boundary that does not exist), and the `approval` service is disabled — nothing in the Windows roster asks for approval, so the model is never told approval exists or that asks are auto-rejected. Keeping fs-only path rules would be theater: the unconfined shell can bypass them with one command, so the honest Windows posture is full access rather than a boundary only the fs tools pretend to enforce. -- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack — or confinement — re-enables the bash rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. -- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` re-declare `dsh-pwsh-local`/`dsh-tool-pwsh`, and `dsh-base` also declares `dsh-fs-local`; the base bundle lists every row plugin as a dependency by house style. +- **The platform layer is a data file, not a roster rewrite.** `@deepseek-ai/dsh-base` ships [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml) alongside its universal `cordis.patch.yml`. It disables the POSIX-only `bash-sandbox`/`tool-bash` rows and inserts `pwsh-sandbox`/`tool-pwsh`. The later [Windows ACL sandbox decision](2026-08-08-windows-acl-restricted-token-sandbox.md) filled the win32 runner chain and superseded this note's original unconfined roster: `sandbox`, `sandbox-policy`, `fs-sandbox`, `permission`/`ui-permission`, and `approval` now stay enabled exactly as on POSIX, while the ACL backend truthfully reports its Everyone and hard-link gaps as partial enforcement. +- **The launcher injects the layer by platform.** `apps/cli/src/windows-shell.ts` resolves it from the base bundle layer's `packageDir` between the bundle layers and the user layers on `win32` hosts, in every composition path (boot, config-only HMR recomposition, config dumps). Overriding the shipped default is a composition decision: a Windows host that prefers the bash stack re-enables the bash rows and disables both pwsh rows through its profile or home `cordis.patch.yml`. Custom profiles without the base bundle are skipped (they own their shell stack); a base bundle that ships no Windows shell patch fails loud. +- **Module resolution is restored for cold starts.** The profiles-rework CLI dropped the pwsh packages from `apps/cli`'s dependency closure, so `healProfilesModuleFallback` never linked them into `$DSH_HOME/profiles/node_modules` and a fresh Windows host could not resolve the inserted rows. `apps/cli` and `dsh-base` declare `dsh-pwsh-sandbox`/`dsh-tool-pwsh`; the executor's dependency chain supplies `dsh-pwsh-local`, and the base bundle lists every row plugin as a dependency by house style. The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches bash decision](2026-08-05-pwsh-ui-bash-parity.md); the [pwsh tool bash parity decision](2026-08-02-pwsh-tool-bash-parity.md) ships the tool's surface. Nothing in this decision changes POSIX behavior. @@ -24,21 +24,21 @@ The pwsh GUI rendering shipped earlier with the [pwsh UI presentation matches ba **Ship the platform layer from `apps/cli` code instead of a bundle data file.** Rejected: the patch belongs next to the rows it replaces, in the bundle that owns them, so the shipped roster stays visible as composition data and dumps carry its provenance; the launcher contributes only the win32 gate. -**Keep `permission`/`ui-permission` on Windows.** Rejected: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor; making it tolerate an unconfined shell would advertise presets the shell cannot honor. +**Keep `permission`/`ui-permission` on Windows without a confining runner.** Rejected by the original delivery: `dsh-permission` hard-requires `ctx.bash.sandboxMode` and fails loud at load over an unconfined executor. The later ACL runner removed that premise, so the current roster retains both rows. -**Keep fs path-rule confinement on Windows (`sandbox-policy` + `fs-sandbox` without OS runners).** Rejected: the shell is the model's primary tool and unconfined on Windows, so fs-only path rules are trivially bypassable and would overstate the boundary; the honest posture is full degradation to danger-full-access. +**Keep fs path-rule confinement on Windows without an OS runner.** Rejected by the original delivery: an unconfined shell could bypass fs-only path rules. The current ACL runner confines the shell and the fs provider under one policy, so this rejected half-boundary is no longer the shipped shape. **Ship a `DSH_WINDOWS_SHELL` environment escape hatch.** Rejected: decisive behavior changes belong in composition config, which already overrides the platform layer row by id; a second override channel would split the single source of truth for roster decisions. ## Consequences - A Windows host running a shipped `dsh` surface gets `pwsh` as its shell tool and PowerShell as the `ctx.bash` executor without configuration; `bash` is absent from the model-visible roster there (its tool row is disabled). -- Windows has no sandbox at all: the fs tools run unconfined (`dsh-fs-local`), the approval service is absent (nothing asks for approval, and the model is never told approval exists), and the permission switcher is gone. The model-visible posture is honest full access rather than a boundary the shell can bypass. +- Windows commands and fs operations share the sandbox policy, permission switcher, and approval service. The ACL runner confines writes but reports `enforcement: 'partial'`; explicit `danger-full-access` remains the approved bypass rather than the platform default. - POSIX hosts are unchanged: the platform layer never applies, and the bash stack remains the universal `cordis.patch.yml` rows. -- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-local`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. +- Windows hosts that prefer the bash stack (e.g. with WSL/Git-Bash on PATH) override the shipped default through their profile or home `cordis.patch.yml` — disabling `pwsh-sandbox`/`tool-pwsh` and re-enabling `bash-sandbox`/`tool-bash` (both executors register the same `bash` service, so an incomplete recipe fails loud at load) — composition config is the one override channel. ## Verification -- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, the custom-profile skip, and the missing-patch failure with the platform injected, and composes the REAL shipped bundle layers (dsh-base + dsh-web-app resolved from the app installation) through the boot's patch algorithm to assert the win32 danger-full-access roster and the base-only-profile warning; `packages/bundle/base/tests/base.spec.ts` pins the shipped Windows patch file shape (disables, inserts, and the absent approval service). +- Unit: `apps/cli/tests/windows-shell.spec.ts` pins the win32 default, custom-profile skip, missing-patch failure, cold-start dependency closure, and real composed roster; `packages/bundle/base/tests/base.spec.ts` pins that the Windows layer disables only the bash rows, inserts the confined pwsh rows, and leaves sandbox, permission, fs, and approval ownership untouched. - Keyless: a win32 `dsh --profile --dump-config` shows the pwsh rows with `windows.cordis.patch.yml` provenance and the bash rows disabled; the POSIX dump (CI Linux) is unchanged. - The real-composition smoke boots the web profile on win32 with the pwsh stack mounted (the exact roster this note describes). diff --git a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md index 41a6429eab..a9d600f8a8 100644 --- a/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-01-windows-pwsh-default.zh.md @@ -12,9 +12,9 @@ harness 交付的执行画像在每个平台都是 bash 优先。Windows 主机 启动交付 profile(`dsh web`、`dsh --profile headless`、一次性任务)的 Windows 主机默认获得 PowerShell 栈;POSIX 主机不变。 -- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml):它禁用 `bash-sandbox`/`tool-bash`(仅 POSIX 的执行器及其方言工具)并插入 `pwsh-local`/`tool-pwsh`。Windows 上没有 OS 级 sandbox runner(landlock/bwrap/seatbelt 均为 POSIX 专属),因此该层整体移除 sandbox 栈——`sandbox`、`sandbox-policy`、`fs-sandbox` 被禁用,由不限权的 `dsh-fs-local` 提供 `ctx.fs`——并完全退化为 danger-full-access:`permission`/`ui-permission` 离开清单(dsh-permission 要求有限权能力的执行器——preset 捆绑的是无限制执行器无法兑现的 sandbox 模式;见其构造函数守卫——客户端旋钮会宣传一个并不存在的边界),`approval` 服务也被禁用——Windows 清单里没有任何动作需要审批,模型也不会被告知"审批存在"或"请求会被自动拒绝"。保留仅限 fs 的路径规则是摆设:不限权的 shell 一条命令即可绕过,因此诚实的 Windows 姿态是全权访问,而不是一个只有 fs 工具假装执行的边界。 -- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈(或偏好有限权)的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 -- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 重新声明 `dsh-pwsh-local`/`dsh-tool-pwsh`,`dsh-base` 还声明 `dsh-fs-local`;按仓库惯例,base bundle 把每个行插件都列为依赖。 +- **平台层是数据文件,不是清单重写。** `@deepseek-ai/dsh-base` 随通用 `cordis.patch.yml` 一起交付 [`windows.cordis.patch.yml`](../../../../packages/bundle/base/windows.cordis.patch.yml)。它禁用仅限 POSIX 的 `bash-sandbox`/`tool-bash` 行,并插入 `pwsh-sandbox`/`tool-pwsh`。后续的 [Windows ACL 沙箱决策](2026-08-08-windows-acl-restricted-token-sandbox.md)填充了 win32 runner 链,并取代了本笔记最初的不限权清单:`sandbox`、`sandbox-policy`、`fs-sandbox`、`permission`/`ui-permission` 与 `approval` 均与 POSIX 上一样保持启用,而 ACL 后端则如实把 Everyone 与硬链接缺口报告为部分强制执行。 +- **启动器按平台注入该层。** `apps/cli/src/windows-shell.ts` 在 `win32` 主机上从 base bundle 层的 `packageDir` 解析它,置于 bundle 层与用户层之间,覆盖所有组合路径(启动、config-only HMR 重组合、配置转储)。覆盖交付默认是组合决策:偏好 bash 栈的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 重新启用 bash 行,并禁用两个 pwsh 行。未挂 base bundle 的自定义 profile 被跳过(它们自己拥有 shell 栈);base bundle 缺 `windows.cordis.patch.yml` 时 fail loud。 +- **冷启动的模块解析已恢复。** profiles 重构把 pwsh 包从 `apps/cli` 的依赖闭包中删掉了,`healProfilesModuleFallback` 因此从未把它们链接进 `$DSH_HOME/profiles/node_modules`,新 Windows 主机解析不到插入的行。`apps/cli` 与 `dsh-base` 声明 `dsh-pwsh-sandbox`/`dsh-tool-pwsh`;执行器的依赖链提供 `dsh-pwsh-local`,按仓库惯例,base bundle 把每个行插件都列为依赖。 pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-bash-parity.md) 先行交付;[pwsh 工具与 bash 对齐决策](2026-08-02-pwsh-tool-bash-parity.md) 交付了工具表面。本决策不改变任何 POSIX 行为。 @@ -24,21 +24,21 @@ pwsh GUI 渲染已随 [pwsh UI 呈现与 bash 对齐决策](2026-08-05-pwsh-ui-b **从 `apps/cli` 代码而非 bundle 数据文件交付平台层。** 否决:patch 应放在它替换的行旁边、属于拥有这些行的 bundle,让交付清单作为组合数据保持可见、转储带有出处;启动器只贡献 win32 门控。 -**在 Windows 上保留 `permission`/`ui-permission`。** 否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,在无限制执行器上加载即 fail loud;让它容忍无限制 shell 会宣传 shell 无法兑现的 preset。 +**在 Windows 没有隔离 runner 时保留 `permission`/`ui-permission`。** 最初交付时否决:`dsh-permission` 硬性要求 `ctx.bash.sandboxMode`,并在不限权执行器上加载时 fail loud。后续的 ACL runner 消除了该前提,因此当前清单保留这两行。 -**在 Windows 上保留 fs 路径规则限制(无 OS runner 的 `sandbox-policy` + `fs-sandbox`)。** 否决:shell 是模型的主工具且在 Windows 上不限权,仅限 fs 的路径规则一行命令即可绕过,会夸大边界;诚实的姿态是完全退化到 danger-full-access。 +**在 Windows 没有 OS runner 时保留 fs 路径规则限制。** 最初交付时否决:不限权 shell 可以绕过仅限 fs 的路径规则。当前 ACL runner 用同一策略约束 shell 与 fs 提供方,因此这项被否决的半边界已不是当前交付形态。 **交付 `DSH_WINDOWS_SHELL` 环境变量逃生门。** 否决:决定性的行为变更应集中在组合配置中,而组合配置已能按行 id 覆盖平台层;第二条覆盖通道会分裂清单决策的单一事实来源。 ## 后果 - 运行交付版 `dsh` 表面的 Windows 主机无需配置即获得 `pwsh` 作为 shell 工具、PowerShell 作为 `ctx.bash` 执行器;那里的模型可见清单中没有 `bash`(其工具行被禁用)。 -- Windows 上没有任何沙箱:fs 工具不限权运行(`dsh-fs-local`)、`approval` 服务不存在(没有任何动作需要审批,模型也不会被告知审批存在)、权限切换器消失。模型可见的姿态是诚实的全权访问,而不是一个 shell 可以绕过的边界。 +- Windows 命令与 fs 操作共用沙箱策略、权限切换器和 approval 服务。ACL runner 限制写入,但报告 `enforcement: 'partial'`;显式的 `danger-full-access` 仍是获准的绕过方式,而非平台默认。 - POSIX 主机不变:平台层永不生效,bash 栈仍是通用 `cordis.patch.yml` 的行。 -- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-local`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 +- 偏好 bash 栈的 Windows 主机(例如 PATH 上有 WSL/Git-Bash 时)通过其 profile 或 home 的 `cordis.patch.yml` 覆盖交付默认——禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`(两个执行器注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)——组合配置是唯一的覆盖通道。 ## 验证 -- 单元:`apps/cli/tests/windows-shell.spec.ts` 以平台注入固定 win32 默认、自定义 profile 跳过与缺文件失败,并通过启动所用的 patch 算法组合真实交付的 bundle 层(从应用安装解析的 dsh-base + dsh-web-app)断言 win32 danger-full-access 清单与 base-only profile 警告;`packages/bundle/base/tests/base.spec.ts` 固定交付的 Windows patch 文件形状(禁用、插入与缺席的 approval 服务)。 +- 单元:`apps/cli/tests/windows-shell.spec.ts` 固定 win32 默认、自定义 profile 跳过、缺少 patch 时失败、冷启动依赖闭包和真实组合清单;`packages/bundle/base/tests/base.spec.ts` 固定 Windows 层仅禁用 bash 行、插入受限的 pwsh 行,并且不改变沙箱、权限、fs 与审批的归属。 - Keyless:win32 上的 `dsh --profile --dump-config` 显示带 `windows.cordis.patch.yml` 出处的 pwsh 行、被禁用的 bash 行;POSIX 转储(CI Linux)不变。 - 真实组合冒烟在 win32 上启动 web profile,pwsh 栈挂载成功(即本笔记描述的确切清单)。 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml index fd479c217e..1a4d923540 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md -2026-08-02-session-search-not-shipped-default.md: 65bd72fff76210b726e7562fb8e88e5f8802434a -2026-08-02-session-search-not-shipped-default.zh.md: 4eb0851c1e584b84847b6bb5118c8bb2f3156845 +2026-08-02-session-search-not-shipped-default.md: c1bfd7f8e354a4480c5635619514fe782ea71d2c +2026-08-02-session-search-not-shipped-default.zh.md: 9b80c549425c26055700480dd57f1a0a7d01e4a8 diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md index 65bd72fff7..c1bfd7f8e3 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.md @@ -10,7 +10,7 @@ The [shipped-roster decision](2026-07-31-even-out-shipped-tool-rosters.md) made ## Decision -The shipped TUI, Web, and headless surfaces no longer mount `@deepseek-ai/dsh-tool-session-query`: the row is removed from the shared `cordis.patch.yml`, the now-dangling `disabled` patch in the opt-in [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile goes with it, and the workspace dependency drops from `apps/cli/package.json`. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies. +The shipped TUI, Web, and headless surfaces do not mount `@deepseek-ai/dsh-tool-session-query`, and no shipped agent preset carries it. The consumer stays opt-in exactly as the model-facing-session-query-tools note describes: the ACP example's [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) and its snapshot counterpart remain the mounted reference, and a custom composition can mount the package with the timeout and spill policies. The `ctx.sessionQuery` service itself stays mounted. `session-query-sqlite` remains a base row — the TUI's `session-reference` consumes it for `/resume` — and the Web overlay keeps patching it to an in-memory index for the browser content search. Only the model-facing consumer is removed. diff --git a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md index 4eb0851c1e..9b80c54942 100644 --- a/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md +++ b/.agents/notes/implemented/feature/2026-08-02-session-search-not-shipped-default.zh.md @@ -10,7 +10,7 @@ Status: implemented ## 决策 -交付的 TUI、Web 与无头 surface 不再挂载 `@deepseek-ai/dsh-tool-session-query`:该行从共享的 `cordis.patch.yml` 移除,opt-in 的 [`core-web.cordis.yml`](../../../../apps/cli/config/core-web.cordis.yml) profile 中那条已悬空的 `disabled` patch 也随之删除,workspace 依赖也从 `apps/cli/package.json` 中移除。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。 +交付的 TUI、Web 与无头 surface 均不挂载 `@deepseek-ai/dsh-tool-session-query`,交付的 agent preset 也都不包含它。该消费方仍保持 opt-in,与面向模型的会话查询工具决策所述完全一致:ACP(Agent Client Protocol)示例的 [`session-query.cordis.yml`](../../../../examples/acp-agent/session-query.cordis.yml) 及其快照对侧文件仍是挂载参考,自定义组合也可以连同超时与 spill 策略一起挂载该包。 `ctx.sessionQuery` 服务本身保持挂载。`session-query-sqlite` 仍是 base 的一行,TUI 的 `session-reference` 消费它来实现 `/resume`,Web overlay 也继续把它 patch 成内存索引,供浏览器内容搜索使用。被移除的只有面向模型的消费方。 diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml index 89e150f59c..84cb091651 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md -2026-08-04-claude-code-and-codex-subagent-backends.md: 2063b99a7b0ca34f434b3e56628f3ce765d90d81 -2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 71d4a8e9fc060f8c96483b22641fd67ee1f71c08 +2026-08-04-claude-code-and-codex-subagent-backends.md: ccc96d6c998c4ab958a7eea1e502d036d16ec90d +2026-08-04-claude-code-and-codex-subagent-backends.zh.md: 740eeb633e336d5b01cb0b84fb656612e690959d diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md index 2063b99a7b..ccc96d6c99 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.md @@ -12,7 +12,7 @@ The product integrations must not become second owners for task text, cwd, cance ## Decision -The harness publishes two sibling one-shot providers as independently installable, opt-in packages. A user loads a provider and the existing common subagent tool in their own `cordis.yml`: `subagent_codex` binds `codex`, while `subagent_claude_code` binds `claude-code`. The shipped CLI dependency closure and base, Web, and headless configurations load neither provider. Each tool accepts only a standalone text task; product selection and background execution are not model arguments. +The harness publishes two sibling one-shot provider packages: `codex` and `claude-code`. This note owns their product protocols, result mapping, and process lifecycle; the [shared-profile-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md) supersedes the original opt-in composition placement. Loading either provider starts no product process, and each tool accepts only a standalone text task; product selection and background execution are not model arguments. Both providers report `inheritsParentContext: false`, advertise no optional start capabilities, and pass the parent Session cwd without copying the parent conversation. Their documented tools disable background execution and use `maxDepth: 'provider-managed'`, leaving recursion policy with the out-of-process product instead of sending a limit the provider cannot enforce. Every call creates a fresh product process and a non-resumable product conversation. The shared subagent service continues to own request resolution, lifecycle events, result settlement, and foreground collection; the shared subprocess service owns credential scrubbing, process-tree termination, and whole-tree exit observation. @@ -47,7 +47,7 @@ Codex 0.147.0 speaks the Responses protocol, while DeepSeek's public OpenAI-comp ## Claude Code provider -`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. The SDK's platform `optionalDependency` supplies the real Claude Code 2.1.220 CLI. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` command, arguments, cwd, environment, and forwarded signal unchanged to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. +`@deepseek-ai/dsh-subagent-claude-code` registers the fixed `claude-code` provider and invokes `@anthropic-ai/claude-agent-sdk@0.3.220`. Before each run, the provider resolves the fixed `claude` name through the host subprocess execution world and passes that exact path as `pathToClaudeCodeExecutable`; the SDK therefore uses the native product that launched DSH rather than selecting its platform `optionalDependency`. A Windows `.cmd` or `.bat` path crosses `cmd.exe /v:off` as a quoted per-spawn environment expansion, so percent, ampersand, and exclamation path components remain data without changing the shared subprocess contract. The provider uses the official `query()` entrypoint and passes the SDK's `spawnClaudeCodeProcess` arguments, cwd, environment, and forwarded signal to `dsh-subprocess`; its private `SpawnedProcess` adapter exposes only the stream, event, kill, and exit facts the SDK requires. The public configuration contains the same two deployment-owned values as the Codex sibling: an explicit `env` overlay and a positive finite `disposeGraceMs` no greater than the repository's shared `MAX_TIMER_DELAY_MS`. Each run creates its own `AbortController`, sets `persistSession: false`, and disables `AskUserQuestion`. The provider deliberately omits `settingSources`, so the SDK reads the host's normal user, project, and local Claude settings relative to the parent Session cwd. It neither copies nor filters those settings and does not create or modify login state. It supplies no `canUseTool`, elicitation, or dialog callback, so unattended interactions fail through the SDK rather than waiting for a user interface the provider does not own. @@ -65,7 +65,7 @@ The Codex evidence pins `@openai/codex@0.147.0` and `codex-cli 0.147.0`. Its rea The Codex credentialed e2e registers the production provider, starts the same real app-server, and requests one random nonce through the test-private bridge described above. It fixes the external endpoint and model, stores no credential or request payload, requires exactly one completed upstream response, compares the trimmed product answer byte-for-byte with the nonce, and waits for every managed handle to exit. -The Claude Code evidence pins Agent SDK 0.3.220 and its platform-distributed Claude Code 2.1.220 CLI. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, and whole-tree exit. The Loader e2e resolves both product packages by name while neither product command is available and records zero child starts. +The Claude Code evidence pins Agent SDK 0.3.220 and uses its platform-distributed Claude Code 2.1.220 CLI as the deterministic compatibility fixture, routed through the same native executable-resolution path production uses. Its real-product spec observes the exact `x-api-key`, original task, byte-exact final answer, inherited temporary host-setting marker, process failure, local cancellation, whole-tree exit, and a real Windows batch shim under a path containing percent, ampersand, and exclamation metacharacters. This evidence proves the official SDK/CLI integration path, not compatibility with every independently installed product version. The Loader and shipped-profile evidence resolve both product packages by name while starting neither product, and the provider suite proves that the SDK receives the executable resolved from the host `PATH`. The Claude Code credentialed e2e maps the key and fixed official endpoint only in the provider's in-memory environment, uses the documented `deepseek-v4-pro[1m]` and `deepseek-v4-flash` model variables, and traverses the production provider, official SDK, and real CLI. It compares the trimmed result with a random nonce and proves whole-tree exit without calling the Messages API directly from the test. @@ -87,7 +87,7 @@ The project owner's distribution authorization is scoped to the official `@anthr ## Consequences -Users can install either or both product providers, bind stable foreground tools in their own Cordis configuration, and delegate one self-contained task through the existing subagent contract. Official product integrations preserve native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. +Users delegate through two stable foreground tools backed by the official product integrations. Their Profile placement and per-Preset exposure are owned by the [shared-host placement decision](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md); this note's provider lifecycle keeps native settings and behavior while shared services retain the sole ownership of task settlement and process-tree quiescence. Every delegation pays for a fresh product process and independent model context, and only final text reaches the parent. Product-native configuration makes behavior depend on the deployment's installed product, account state, and workspace settings. Credentialed e2e runs also spend external API quota and depend on the official DeepSeek endpoint; deterministic protocol, failure, cancellation, and approval coverage remains in the keyless tier. The providers do not resume sessions, stream progress, accept new human interaction, roll back tool or file side effects, or impose a wall-clock timeout. diff --git a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md index 71d4a8e9fc..740eeb633e 100644 --- a/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-claude-code-and-codex-subagent-backends.zh.md @@ -12,7 +12,7 @@ Status: implemented ## 决策 -harness 将两个一次性兄弟提供方作为可独立安装、选择启用的包交付。用户在自己的 `cordis.yml` 中加载提供方与现有的通用 subagent 工具:`subagent_codex` 绑定 `codex`,`subagent_claude_code` 绑定 `claude-code`。随产品交付的 CLI(命令行界面)依赖闭包,以及基础、Web 与 headless 配置都不会加载任一提供方。每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 +harness 交付两个同级的一次性提供方包:`codex` 与 `claude-code`。本说明负责它们的产品协议、结果映射和进程生命周期;[共享 profile 宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)取代原先由用户选择启用的组装位置。加载任一提供方都不会启动产品进程,而且每个工具只接受独立文本任务;产品选择与后台执行都不作为模型参数。 这两个提供方都报告 `inheritsParentContext: false`,不声明任何可选的启动能力,并传递父会话 cwd,但不会复制父级对话。文档所示的工具会禁用后台执行,并使用 `maxDepth: 'provider-managed'`,将递归策略留给进程外产品,而不是发送提供方无法强制执行的限制。每次调用都会创建一个全新的产品进程和一次不可续接的产品对话。共享 subagent 服务继续负责请求解析、生命周期事件、结果结算和前台收集;共享子进程服务负责凭证清洗、进程树终止以及整棵进程树的退出观测。 @@ -47,7 +47,7 @@ Codex 0.147.0 使用 Responses 协议,而 DeepSeek 的公开 OpenAI 兼容端 ## Claude Code 提供方 -`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。SDK 的平台 `optionalDependency` 提供真实的 Claude Code 2.1.220 CLI。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 命令、参数、cwd、环境和转发的信号原样传入 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 +`@deepseek-ai/dsh-subagent-claude-code` 注册固定的 `claude-code` 提供方,并调用 `@anthropic-ai/claude-agent-sdk@0.3.220`。每次运行前,提供方经宿主 subprocess 执行世界解析固定名称 `claude`,并把准确路径作为 `pathToClaudeCodeExecutable` 交给 SDK;SDK 因此使用启动 DSH 的原生产品,而不是选择自身的 platform `optionalDependency`。Windows `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境展开值穿过 `cmd.exe /v:off`,因此路径中的百分号、与号和感叹号仍只是数据,且无需改变共享子进程约定。提供方使用官方 `query()` 入口点,并将 SDK 的 `spawnClaudeCodeProcess` 参数、cwd、环境和转发的信号交给 `dsh-subprocess`;其私有 `SpawnedProcess` 适配器只公开 SDK 所需的流、事件、终止和退出事实。 公开配置包含与 Codex 兄弟提供方相同、由部署方负责的两个值:显式的 `env` 覆盖项,以及须为正有限值且不得大于仓库共享 `MAX_TIMER_DELAY_MS` 的 `disposeGraceMs`。每次运行都会创建自己的 `AbortController`,设置 `persistSession: false` 并禁用 `AskUserQuestion`。提供方故意省略 `settingSources`,因此 SDK 会相对于父会话 cwd 读取宿主机常规的用户、项目和本地 Claude 设置。它既不复制也不过滤这些设置,也不会创建或修改登录状态。提供方不设置 `canUseTool`、elicitation 或对话回调,因此无人值守交互会经 SDK 失败,而不会等待本提供方不负责的用户界面。 @@ -65,7 +65,7 @@ Codex 证据锁定 `@openai/codex@0.147.0` 与 `codex-cli 0.147.0`。其真实 带密钥 Codex e2e 会注册生产提供方,启动同样的真实 app-server,并通过上述测试专用桥接层请求一个随机数。该测试固定外部端点与模型,不存储任何凭据或请求载荷,要求上游恰好完成一次响应,将去除首尾空白后的产品答案与该随机数逐字节比较,并等待所有受管句柄退出。 -Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1.220 CLI。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消以及整棵进程树退出。Loader e2e 会在两个产品命令均不可用时按名称解析两个产品包,并记录零次子级启动。 +Claude Code 证据锁定 Agent SDK 0.3.220,并使用 SDK 按平台分发的 Claude Code 2.1.220 CLI 作为确定性兼容性 fixture(测试前置数据),且该 fixture 经生产环境所用的同一原生可执行文件解析路径运行。其真实产品测试会观测确切的 `x-api-key`、原始任务、逐字节完全一致的最终回答、继承的临时宿主设置标记、进程失败、本地取消、整棵进程树退出,以及位于同时含百分号、与号和感叹号路径中的真实 Windows batch shim。这项证据证明官方 SDK/CLI 集成路径,而不证明它与每个独立安装的产品版本兼容。Loader 与随附 profile 证据会按名称解析两个产品包且不启动产品,provider 测试则证明 SDK 收到由宿主 `PATH` 解析出的可执行文件。 带密钥 Claude Code e2e 仅在提供方的内存环境中映射密钥与固定的官方端点,把模型变量设为文档所示的 `deepseek-v4-pro[1m]` 与 `deepseek-v4-flash`,并实际经过生产提供方、官方 SDK 与真实 CLI。它将去除首尾空白后的结果与一个随机数比较,并证明整棵进程树退出,且测试不会直接调用 Messages API。 @@ -79,7 +79,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1 **面向模型的产品选择器。** 产品可用性和身份验证属于部署事实。两个固定工具使各自的 schema 与提供方绑定保持明确,也避免在通用服务中添加动态选择状态。 -**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture(测试前置数据)。 +**以产品替身作为强制证据。** 替身可以穷尽覆盖私有协议分支,但无法证明包导出、官方发行版、身份验证或真实进程行为。强制证据会驱动每个官方产品连接回环模型 fixture。 **由插件管理登录、产品主目录、模型、设置或权限。** 这些选择会在每个产品的原生配置之外建立另一套权威来源,并将一次性提供方扩张为账户管理功能。提供方只公开显式环境覆盖项和清理宽限期;无人值守交互会以默认拒绝方式失败。 @@ -87,7 +87,7 @@ Claude Code 证据锁定 Agent SDK 0.3.220 及其平台分发的 Claude Code 2.1 ## 后果 -用户可以安装任一或两个产品提供方,在自己的 Cordis 配置中绑定稳定的前台工具,并通过现有 subagent 约定委派一项自包含任务。官方产品集成会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 +用户通过官方产品集成支持的两个稳定前台工具进行委派。它们在 Profile 中的归属和按 Preset 暴露方式由[共享宿主归属决策](../architecture/2026-08-10-product-subagent-providers-in-shared-host.md)负责;本说明规定的提供方生命周期会保留原生设置与行为,而共享服务继续独占任务结算与进程树完全停稳的责任。 每次委派都要承担新建产品进程和独立模型上下文的开销,且只有最终文本会到达父级。产品原生配置使行为取决于部署环境中安装的产品、账户状态和工作区设置。带密钥 e2e 运行还会消耗外部 API 配额,并依赖 DeepSeek 官方端点;对协议、失败、取消与审批的确定性覆盖仍由无密钥层级承担。提供方不会恢复会话、以流式方式传送进度、接受新的人工交互、回滚工具或文件副作用,也不会施加按实际经过时间触发的超时。 diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml index 86b48310e7..362aef352f 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md -2026-08-04-web-context-source-and-steer-marks.md: 01bdca873a847f70b4b8632b961e01e099ae4f04 -2026-08-04-web-context-source-and-steer-marks.zh.md: b6a9cc5692826b402b5a08ec65a5c8fc3c547b6b +2026-08-04-web-context-source-and-steer-marks.md: d4fee3ee25aceaf05106d6bd1bdb73e7c51c3f78 +2026-08-04-web-context-source-and-steer-marks.zh.md: 8e0ffa6c15ea7506e1aaed9f0b142925727856aa diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md index 01bdca873a..d4fee3ee25 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.md @@ -14,13 +14,13 @@ The distinctions are already durable. Every producer must supply a merge-extensi The transcript names all three roles a non-prompt message can play — injected context, recalled session, and steering. -`TranscriptAdapter` and the history fold attach a `provenance` view containing the producer role and label to every `ContextMessageNode`; `contextProvenance()` computes it from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the 141px scrollport and truncation bound are unchanged from the [archived disclosure decision](../../archived/feature/2026-07-30-web-context-injection-disclosure.md). What renders inside that scrollport is chosen by the independent form axis added in the [context form decision](2026-08-05-context-form-vocabulary.md). +The Chat Message Definition attaches a `provenance` view containing the producer role and label to every `ContextMessageNode`; `contextProvenance()` computes it from the durable source alone. It returns a `role` (`inject`, or `recall` for a cross-session snapshot) and a `label` naming the producer. `ContextInjectionRow` titles itself from the role and shows the label beside that title in `ToolRow`'s summary geometry, so the collapsed row already answers what was added and by whom; the 141px scrollport and truncation bound are unchanged from the [archived disclosure decision](../../archived/feature/2026-07-30-web-context-injection-disclosure.md). What renders inside that scrollport is chosen by the independent form axis added in the [context form decision](2026-08-05-context-form-vocabulary.md). **The label is read out of the log, never from a client-side table of producer names.** `workspace-instructions` is named by the distinct instruction paths it reconciled, `session-reference` by the titles of the sessions it read, a plugin source by its logged plugin id, and any other source by its own `kind` — the documented default arm for a merge-extensible union. A source carrying no readable kind degrades to an unnamed injection. A new or renamed producer is therefore identifiable without a client release, no label can go stale against the code, and a resumed, forked, or foreign log projects exactly like a live session. `recall` covers `session-reference` because that is the one shipped source that lifts another session's material into this one. No Web leaf mounts `dsh-session-reference` today — it had only a terminal host — so the arm exists for log portability rather than for a bundled producer, and it is exercised by unit coverage rather than an assembled Web scenario. -`MessageItem` captions durable and pending steering bubbles with `插话`. The runtime replays durable `agent/inbox/spliced` events and projects a user-origin `user/message` as `SteeringMessageNode` when that same message identity was claimed from `next-step`; a queued-turn claim stays a `UserMessageNode`, and a non-user next-step message stays context. This reverses one clause of the [archived no-steer decision](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners. +`MessageItem` captions durable and pending steering bubbles with `插话`. The Chat Inbox and Message Definitions replay durable `agent/inbox/spliced` events and project a user-origin `user/message` as `SteeringMessageNode` when that same message identity was claimed from `next-step`; a queued-turn claim stays a `UserMessageNode`, and a non-user next-step message stays context. This reverses one clause of the [archived no-steer decision](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md), which removed the badge because the composer could not steer and the label named a gesture users could not perform. The composer gained a Steer gesture afterwards without amending that note; this decision supplies the product decision its reintroduction clause required, and corrects the stale facts left in it. The caption is the only steering chrome here: composer modes, the Queue dock's strict-steer action, and pending-steering lifecycle stay with their own owners. ## Alternatives considered diff --git a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md index b6a9cc5692..8e0ffa6c15 100644 --- a/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md +++ b/.agents/notes/implemented/feature/2026-08-04-web-context-source-and-steer-marks.zh.md @@ -14,13 +14,13 @@ Status: implemented transcript 为非提示消息可能承担的三种角色分别命名:注入上下文、召回会话、steering。 -`TranscriptAdapter` 与历史折叠为每个 `ContextMessageNode` 附加一份包含生产者角色和名称的 `provenance` 视图;`contextProvenance()` 仅依据持久来源计算该视图。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;141px 滚动视口与截断上限沿用[已归档的展开项决策](../../archived/feature/2026-07-30-web-context-injection-disclosure.md),未作改动。视口里渲染什么,则由[上下文形态决策](2026-08-05-context-form-vocabulary.md)引入的、相互独立的形态轴决定。 +Chat Message Definition 为每个 `ContextMessageNode` 附加一份包含生产者角色和名称的 `provenance` 视图;`contextProvenance()` 仅依据持久来源计算该视图。它返回 `role`(`inject`,跨会话快照则为 `recall`)与命名生产者的 `label`。`ContextInjectionRow` 以角色作为标题,并按 `ToolRow` 摘要的几何在标题旁展示该名称,因此折叠态就已经回答了「注入了什么、由谁注入」;141px 滚动视口与截断上限沿用[已归档的展开项决策](../../archived/feature/2026-07-30-web-context-injection-disclosure.md),未作改动。视口里渲染什么,则由[上下文形态决策](2026-08-05-context-form-vocabulary.md)引入的、相互独立的形态轴决定。 **名称从日志中读出,绝不来自客户端维护的生产者名称表。** `workspace-instructions` 以它对账过的去重指令文件路径命名,`session-reference` 以它读取的会话标题命名,插件来源以其记录的插件 id 命名,其余来源则以自身的 `kind` 命名——这正是可合并扩展联合类型有文档记载的默认分支。没有可读 kind 的来源降级为无名注入。于是新增或重命名的生产者无需客户端发版即可辨识,任何名称都不会相对代码变味,恢复、fork 或来自外部的日志与实时会话的投影结果完全一致。 `recall` 覆盖 `session-reference`,因为它是当前唯一会把另一个会话的材料搬进本会话的已发布来源。今天没有任何 Web 叶子挂载 `dsh-session-reference`——它此前只有终端宿主——因此该分支的存在是为了日志可移植性,而不是为了某个已打包的生产方,其覆盖来自单元测试而非组装后的 Web 场景。 -`MessageItem` 为持久与待处理的 steering 气泡加上 `插话` 标注。runtime 会重放持久 `agent/inbox/spliced` 事件;如果一条用户来源的消息以相同身份从 `next-step` 被领取,后续 `user/message` 就投影为 `SteeringMessageNode`。从排队轮次领取的消息仍是 `UserMessageNode`,非用户来源的 next-step 消息仍是上下文。这推翻了[已归档的取消 steer 入口与插话装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论。当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。 +Chat Inbox 与 Message Definition 会重放持久 `agent/inbox/spliced` 事件;如果一条用户来源的消息以相同身份从 `next-step` 被领取,后续 `user/message` 就投影为 `SteeringMessageNode`。`MessageItem` 为这种持久消息与待处理 steering 气泡加上 `插话` 标注。从排队轮次领取的消息仍是 `UserMessageNode`,非用户来源的 next-step 消息仍是上下文。这推翻了[已归档的取消 steer 入口与插话装饰决策](../../archived/simplification/2026-07-31-web-ui-no-steer-entry-or-interjection-chrome.md)中的一条结论。当时移除徽章,是因为 composer 无法 steer,标签指向了用户做不到的动作。此后 composer 获得了 Steer 手势,却没有同步修订那份 note;本决策提供了它在「重新引入」条款中要求的产品决策,并订正了其中留下的过时事实。标注是这里唯一的 steering 装饰:composer 模式、Queue dock 的严格 steer 操作、待处理 steering 的生命周期仍归各自的所有者。 ## 考虑过的替代方案 diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml index 24b04ea72f..92610dde43 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md -2026-08-05-per-agent-tool-presentation.md: 348f7ab0a26e9b39057dbac885304e0d52e0b1fb -2026-08-05-per-agent-tool-presentation.zh.md: 4920ee6eb061d44934bfc9f5176e244f5aac8553 +2026-08-05-per-agent-tool-presentation.md: adb93b51c73d341c153b8fcafe2a08f0a5598478 +2026-08-05-per-agent-tool-presentation.zh.md: fa83bd4daec9d8e6e9e43295bb81af97782b1fdf diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md index 348f7ab0a2..adb93b51c7 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.md @@ -12,16 +12,16 @@ The naive reading of "move tools down to the agent plane" does not work. `ctx.to ## Decision -Split the registry from its projection. The registry stays host-plane; the **presentation** becomes per-agent state inside it, alongside the per-agent restrictions and guards that already live there. +Split the registry from its projection. The registry stays host-plane; the **presentation** becomes scope state inside it, alongside the scoped restrictions and guards that already live there. -`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the agent that declared it. `modeFor(scope)` resolves that cell against the config `mode`, which becomes the default for agents declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's. +`ToolRegistry.presentAs(mode)` is scoped-only and mirrors `restrict()`: it writes one cell on the calling scope's `ToolLayer` through `ScopedLayers.effect`, so it unwinds with the scope that declared it. In the shipped Web surface that scope is an agent preset's standing mount — the `code` preset carries the `tool-mode` row — so one declaration covers every agent joined to that preset, and `modeFor(scope)` takes the nearest declaration on the chain. It resolves against the config `mode`, which becomes the default for scopes declaring nothing rather than a process-wide fact. The three reads that decided presentation — the wire schemas, the `run_code` entry in the visibility view, and the generated SDK section — take the scope's mode instead of the service's. Two consequences fell out and are load-bearing: - **`run_code` is appended per scope.** Previously the transport entered every view whenever the transport existed. Per-agent, a native agent must not find `run_code` in its dispatch table because some other agent in the process presents it — so the append is conditional on that scope's own mode, and the transport is built lazily on first need. - **The reserved name is now unconditional.** `run_code` was rejected as a registration only while a code mode was configured. Any agent may now select a code mode, so a name that was free to take under a native deployment would become a collision the moment a preset mounted. -The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per agent by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section. +The SDK prompt section is registered globally by a code-mode deployment (unchanged) and additionally per scope by `presentAs`, where it shadows by name. Its body renders empty for a native scope, which the prompt renderer drops — that is what keeps an agent opting OUT of a code-mode deployment free of an SDK section. The preset expresses the choice through one row, `@deepseek-ai/dsh-agent-tool-mode`, whose whole body is a `presentAs` call. A code mode waits for `ctx.codeRuntime` through `ctx.inject` rather than assuming it: the runtime is host-plane, and a pending row is what `dsh-agent-presets` already reports as an unusable mount, naming the row — so a preset selecting Code Mode against a runtime-less deployment fails where an operator can act. diff --git a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md index 4920ee6eb0..fa83bd4dae 100644 --- a/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md +++ b/.agents/notes/implemented/feature/2026-08-05-per-agent-tool-presentation.zh.md @@ -12,16 +12,16 @@ agent preset 已经能按会话组装一个 agent 的工具,却管不了这些 ## Decision -把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 agent 的状态,与已经住在那里的按 agent 限制和守卫并列。 +把注册表和它的投影拆开。注册表留在宿主平面;**呈现方式**变成它内部按 scope 的状态,与已经住在那里的作用域限制和守卫并列。 -`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 agent 一起卸载。`modeFor(scope)` 将该单元与 config 的 `mode` 一并解析,后者于是成为「未作声明的 agent」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。 +`ToolRegistry.presentAs(mode)` 只接受 scoped 上下文,形状照抄 `restrict()`:它通过 `ScopedLayers.effect` 在调用方 scope 的 `ToolLayer` 上写一个单元,因此会随声明它的那个 scope 一起卸载。在随附的 Web 界面里那个 scope 是某个 agent preset 的常驻挂载——`code` preset 携带 `tool-mode` 行——因此一份声明覆盖加入该 preset 的每个 agent,而 `modeFor(scope)` 取作用域链上最近的那份声明。它与 config 的 `mode` 一并解析,后者于是成为「未作声明的 scope」的默认值,而不再是进程级事实。原先决定呈现方式的三处读取——wire schema、可见性视图里的 `run_code` 条目、以及生成的 SDK 段——改为读取该 scope 的模式,而非服务的。 有两个随之而来的结果,且都是承重的: - **`run_code` 按 scope 追加。** 此前只要传输存在,它就进入每一个视图。按 agent 之后,一个 native agent 不能因为进程里别的 agent 呈现了它、就在自己的分发表里看到 `run_code`——因此这次追加以该 scope 自身的模式为条件,传输也改为首次需要时才构建。 - **保留名现在无条件生效。** `run_code` 此前只在配置了 code 模式时才被拒绝注册。如今任何 agent 都可能选择 code 模式,因此一个在 native 部署下可以随便占用的名字,会在某个 preset 挂载的那一刻变成冲突。 -SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 agent 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。 +SDK 提示词段由 code 模式的部署全局注册(不变),并由 `presentAs` 额外按 scope 注册一份,后者按名字遮蔽前者。它的正文对 native scope 渲染为空,而提示词渲染器会丢弃空段——正是这一点让「在 code 模式部署下选择退出」的 agent 不带 SDK 段。 preset 用一行来表达这个选择:`@deepseek-ai/dsh-agent-tool-mode`,其全部内容就是一次 `presentAs` 调用。code 类模式通过 `ctx.inject` 等待 `ctx.codeRuntime` 而非假定它存在:运行时在宿主平面,而一个 pending 的行正是 `dsh-agent-presets` 已经会报告的「不可用挂载」并会指名该行——于是在无运行时的部署上选择 Code Mode 的 preset,会在操作者能够动手的地方失败。 diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml new file mode 100644 index 0000000000..2f6713b4ab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.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-08-06-mcp-client-auto-reconnect.md +2026-08-06-mcp-client-auto-reconnect.md: 99a8aec1abe3713822f8f17c17d8efaca5d61a4d +2026-08-06-mcp-client-auto-reconnect.zh.md: 8d4dc935e6edee9a05f556774e743e48234ca709 diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md new file mode 100644 index 0000000000..99a8aec1ab --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md @@ -0,0 +1,48 @@ +# Agent Note: MCP client auto-reconnect with bounded backoff + +Status: implemented + +English | [中文](2026-08-06-mcp-client-auto-reconnect.zh.md) + +## Problem + +The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. When a stdio server crashed or was killed, its registered tools stayed visible but every call failed with `Not connected` until a human edited the config (HMR) or restarted the Host — v1 explicitly deferred reconnection. Long-running hosts (ACP automation, web) cannot be bounced because a child process died, and for stdio the harness composition is the only party that can respawn it. External feedback escalated this as a real operational gap (issue #1746). + +## Decision + +`packages/mcp/mcp-client/src/connection.ts` owns a per-instance connection supervisor; `apply()` shrinks to config resolution plus two effects (the `serverName` reservation and the supervisor's lifecycle). The supervisor owns the client/transport generations, the live tool registrations, and the reconnect loop. + +**Trigger.** The supervisor arms `client.onclose` per generation. The SDK fires it when the stdio child exits, so a crash is observed without polling. `StreamableHTTPClientTransport` fires `onclose` only for deliberate closes — it owns its internal SSE-stream recovery and surfaces request failures per call — so HTTP servers are effectively outside supervisor restarts; the package README records that limitation. + +**Generations without interleaving.** Each attempt builds a fresh transport and `Client` (the SDK binds a Protocol to one transport for life). One per-supervisor queue serializes every `syncTools` call — initial syncs and `list_changed` re-syncs across all generations — and an `isCurrent` fence makes stale generations inert, so no two syncs can interleave the dispose-previous/register-next swap (which would double-dispose one generation and leak another). The queue also closes a pre-existing race where two rapid `list_changed` notifications re-synced concurrently. The activation attempt, rather than the first queue entrant, explicitly owns strict startup registration: an early `list_changed` notification uses contained re-sync semantics and cannot consume `failOnStartupError`. Failure signals are idempotent per generation: a connect rejection racing its own transport close schedules exactly one retry. A failed attempt cannot enter backoff until both `Client.close()` settles and the transport reports `onclose`, which for stdio proves the child exited; a missing close signal stops reconnection after the SDK's bounded termination window instead of allowing two server processes to overlap. Disposal uses the same bounded close-signal barrier and reports an incomplete shutdown without ever restarting. + +**Bounded backoff with an outage budget.** Delays double from `initialDelayMs` up to `maxDelayMs`. One outage shares `maxAttempts` consecutive failed attempts; exhaustion unregisters the server's tools, logs at error level, and stops until disposal or reload. A connection that survives past the stability window — `maxDelayMs`, derived rather than a fifth tunable, as the longest configured backoff spacing — resets the budget, so an occasionally-crashing server recovers indefinitely while a crash loop whose connects briefly succeed cannot launder its budget into a restart storm. + +**Config and resolution.** Both transports accept `reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }` with schemastery defaults (on, 500ms, 30s, 10). `resolveReconnectPolicy()` is the explicit resolve step: it re-judges every bound and cross-field constraint because programmatic construction may bypass Schemastery, and misconfiguration fails the plugin instance at load. + +**Observable states.** An initial or retry-attempt failure says `connection failed`; an established generation ending says `connection lost`. Retrying logs at warn with attempt count and delay, recovery at info, final failure and disabled recovery at error. During an outage the last good generation stays registered and calls against it fail — deterministic public names mean a recovered unchanged tool list reproduces identical definitions, keeping the model-visible schema prefix stable instead of flapping. With `reconnect.enabled: false` a lost connection keeps the v1 manual-recovery behavior. + +**Disposal.** Dispose flips the fence, cancels any pending timer, closes the current client, then awaits the in-flight attempt and the sync queue before unregistering — quiescence, not just a request to stop. The reconnect timer is unref'd so a waiting backoff never holds a finishing process open. + +## Alternatives considered + +**Consecutive-failure counter that resets on every successful connect.** Rejected: a crash-looping server whose connects briefly succeed would reset the budget each cycle and restart forever — exactly the restart storm the failure cap exists to prevent. The uptime-gated reset distinguishes a recovered server from a looping one without new configuration. + +**Reuse one SDK `Client` across reconnects.** The Protocol clears its transport on close and can technically connect again, but the SDK's own guidance is one connection per Protocol instance, and reuse carries notification handlers and negotiated capability state across server incarnations. A fresh `Client` per generation plus the `isCurrent` fence is unambiguous. + +**Unregister tools immediately on disconnect, re-register on recovery.** Rejected: a transient outage would flap the model-visible tool list (two schema-prefix invalidations per crash) for no information gain; failing calls already signal the outage, and the swap on recovery is atomic per generation. Tools are unregistered at final failure so a permanently dead server does not leak permanently broken tools. + +**Route Streamable HTTP request failures into the supervisor.** Rejected for now: the HTTP transport already reconnects its SSE stream with its own backoff, per-request errors do not imply a dead server, and there is no child process the harness could respawn. Transport close stays the single trigger. + +**Restart through Loader/HMR machinery instead of an in-plugin supervisor.** Rejected: the Loader owns config-driven recomposition, not runtime health. A plugin restarting itself through the Loader would conflate config generations with connection generations and lose the per-outage budget. + +## Testing + +Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, diagnostics distinguish initial or retry failure from established connection loss, strict startup registration survives a pre-connect `list_changed` notification, failed initialization waits for the old generation's close signal and fails closed when that signal never arrives, disposal waits for the same signal with a bounded incomplete-shutdown path, the failure cap unregisters tools and stops, dispose cancels a pending backoff and quiesces an in-flight sync, a close after dispose schedules nothing, disabled mode keeps the v1 behavior, the stability window resets the budget while a crash loop exhausts it, double failure signals schedule one retry, stale generations and handlers are inert, and `resolveReconnectPolicy` rejects each invalid bound. E2E (`tests/mcp-client.e2e.ts`, keyless): the fixture server gained a `crash` tool that replies then exits; real-process tests prove a stdio crash recovers end to end and that unloading the plugin mid-outage stops reconnection promptly. Snapshot: deliberately none, per the original note's rationale — reconnection adds no new presentation shape, and a snapshot composition spawning a crashing server would make replays timing-dependent. + +## Consequences + +- A crashed stdio MCP server recovers without human intervention: bounded backoff, re-discovery, atomic generation swap. Default policy retries an outage for roughly 2.5 minutes before giving up. +- Connection state is genuinely more intricate than connect-once — the partial-availability window v1 avoided now exists (registered tools failing during an outage), concentrated in one module with the invariants named. +- `reconnect` is new config surface on both transports, and the stability window is deliberately derived from `maxDelayMs`; making it independently tunable is a compatible future change. +- After final failure or with reconnect disabled, the plugin stays loaded with no (or failing) tools until reload — deliberate and logged, so a chronically broken server cannot restart forever. diff --git a/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md new file mode 100644 index 0000000000..8d4dc935e6 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md @@ -0,0 +1,48 @@ +# Agent Note: MCP client auto-reconnect with bounded backoff + +Status: implemented + +[English](2026-08-06-mcp-client-auto-reconnect.md) | 中文 + +## 问题 + +[MCP 客户端](2026-07-07-mcp-client-plugin.md)在插件加载时仅连接一次。stdio 服务器崩溃或被终止后,其已注册的工具仍然可见,但每次调用均以 `Not connected` 失败,直到人工编辑配置触发 HMR(热模块替换)重载,或重启 Host——v1 明确推迟了重连机制。长时间运行的 Host(ACP 自动化、Web)不能因为子进程死亡就被重启;而对于 stdio 传输,harness 组合层是唯一能重新拉起子进程的一方。外部反馈将此升级为真实的运维缺口(issue #1746)。 + +## 决策 + +`packages/mcp/mcp-client/src/connection.ts` 拥有一个逐实例的连接监督器;`apply()` 收缩为配置解析加两个副作用(`serverName` 预留和监督器的生命周期)。监督器负责管理 client/transport 代、活跃的工具注册以及重连循环。 + +**触发条件。** 监督器在每一代上挂载 `client.onclose`。SDK 在 stdio 子进程退出时触发该回调,因此崩溃无需轮询即可感知。`StreamableHTTPClientTransport` 仅在主动关闭时触发 `onclose`——它内部拥有自己的 SSE(Server-Sent Events)流恢复机制,并将请求失败以逐调用方式暴露——因此 HTTP 服务器实际上不在监督器的重启范围内;包 README 记录了该限制。 + +**代隔离,无交错。** 每次尝试构建一个全新的 transport 和 `Client`(SDK 将一个 Protocol 绑定到一个 transport 上终身使用)。每个监督器内部有一个队列将所有 `syncTools` 调用串行化——跨所有代的初始同步和 `list_changed` 再同步——`isCurrent` 栅栏使过时的代变为惰性,从而确保不会有两次同步交错执行 dispose 上一代/注册下一代的切换(否则会对同一代执行两次 dispose 并泄漏另一代)。该队列还消除了一个先前存在的竞态:两次快速的 `list_changed` 通知同时触发重新同步。严格启动注册由激活尝试本身显式拥有,而非由首个入队者拥有;提前到达的 `list_changed` 采用故障隔离的再同步语义,不能消费 `failOnStartupError`。失败信号按代幂等:一次连接拒绝与其自身 transport 关闭竞态时,仅调度恰好一次重试。失败尝试只有在 `Client.close()` 结算且 transport 报告 `onclose` 后才能进入退避;对 stdio 而言,`onclose` 证明子进程已退出;若关闭信号始终未到,则在 SDK 的有界终止窗口结束后停止重连,而不是允许两个服务器进程重叠运行。dispose 使用同一个有界关闭信号屏障;若关停未完成则予以报告,且绝不重启。 + +**有界退避与故障预算。** 延迟从 `initialDelayMs` 起逐次翻倍,上限为 `maxDelayMs`。一次故障期间共享 `maxAttempts` 次连续失败尝试的预算;耗尽后注销该服务器的工具、以 error 级别记录日志并停止,直到 dispose 或重新加载。连接在存活超过稳定窗口——即 `maxDelayMs`,作为最长退避间隔从配置推导得出而非作为第五个独立调参项——之后重置预算;因此偶尔崩溃的服务器可无限恢复,而连接短暂成功后立即再次崩溃的循环无法将其预算洗白为重启风暴。 + +**配置与解析。** 两种传输均接受 `reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }` 配置,Schemastery 默认值为(启用、500ms、30s、10)。`resolveReconnectPolicy()` 是显式的解析步骤:它重新校验每个边界值和跨字段约束,因为程序化构造可能绕过 Schemastery,配置错误在加载时即令插件实例失败。 + +**可观测状态。** 初始尝试或重试尝试失败时记录 `connection failed`,已建立的代结束时记录 `connection lost`;重试的 warn 日志包含尝试次数和延迟,恢复以 info 级别记录,最终失败和禁用重连时的断连以 error 级别记录。故障期间,上一个正常代保持注册,对其工具的调用返回失败——确定性公开名称意味着恢复后未变化的工具列表会复现相同的定义,保持模型可见 schema 前缀稳定而非反复抖动。设置 `reconnect.enabled: false` 后,断连保持 v1 的手动恢复行为。 + +**资源释放。** dispose 翻转栅栏、取消待执行的定时器、关闭当前 client,然后等待正在进行的尝试和同步队列完成后再注销工具——完全停稳,而非仅发出停止请求。重连定时器使用 unref,因此等待中的退避不会阻止进程正常退出。 + +## 曾考虑的替代方案 + +**连续失败计数器,每次成功连接即重置。** 否决:连接短暂成功后立即崩溃的循环服务器会在每个周期重置预算并永远重启——恰恰是失败上限旨在防止的重启风暴。基于运行时间的重置能区分已恢复的服务器与循环崩溃的服务器,无需新增配置。 + +**跨重连复用同一个 SDK `Client`。** Protocol 在关闭时清除其 transport,技术上可以再次连接,但 SDK 自身的指导方针是每个 Protocol 实例对应一次连接,且复用会将通知处理器和已协商的能力状态带入新的服务器实例。每代创建全新 `Client` 加 `isCurrent` 栅栏的方式无歧义。 + +**断连时立即注销工具,恢复时重新注册。** 否决:短暂故障会使模型可见工具列表抖动(每次崩溃触发两次 schema 前缀失效),而无任何信息增益;失败的调用已足以标示故障,恢复时的切换按代原子执行。工具仅在最终失败时注销,确保永久死亡的服务器不会泄漏永久失效的工具。 + +**将 Streamable HTTP 请求失败路由到监督器。** 暂不采纳:HTTP 传输已使用自己的退避机制重连其 SSE 流,逐请求错误并不意味着服务器已死,且 harness 没有可重新拉起的子进程。transport 关闭仍是唯一触发条件。 + +**通过 Loader/HMR 机制重启,而非使用插件内监督器。** 否决:Loader 负责配置驱动的重组合,而非运行时健康管理。插件通过 Loader 重启自身会混淆配置代与连接代,并丢失逐故障预算。 + +## 测试 + +单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、诊断区分初始或重试尝试失败与已建立连接丢失、严格启动注册在连接前收到 `list_changed` 通知后仍然生效、初始化失败会等待旧代的关闭信号,若该信号始终未到则停止重连、dispose 同样等待同一关闭信号,并在有界等待到期时报告关停未完成、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 + +## 后果 + +- 崩溃的 stdio MCP 服务器无需人工干预即可恢复:有界退避、重新发现、原子代切换。默认策略对一次故障大约重试 2.5 分钟后放弃。 +- 连接状态确实比一次性连接更复杂——v1 刻意回避的部分可用窗口现已存在(故障期间已注册工具返回失败),集中在一个模块中并命名了所有不变式。 +- `reconnect` 是两种传输上的新配置表面,稳定窗口刻意从 `maxDelayMs` 推导;将其设为独立可调参数是兼容的未来变更。 +- 最终失败后或禁用重连时,插件保持加载状态但无(或失败的)工具,直到重新加载——行为是刻意的且有日志记录,确保长期故障的服务器不能永远重启。 diff --git a/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.i18n.yaml new file mode 100644 index 0000000000..b5c7f142f1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.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-08-07-feedback-acknowledgement-sharing-disclosure.md +2026-08-07-feedback-acknowledgement-sharing-disclosure.md: 1e9cd0fb95d78aff9f6434e0583154e2c3f847da +2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md: ac26b18ad523feeabc297b212210dd73eff93a0a diff --git a/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md new file mode 100644 index 0000000000..1e9cd0fb95 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.md @@ -0,0 +1,27 @@ +# Agent Note: Feedback acknowledgement sharing disclosure + +Status: implemented + +English | [中文](2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md) + +## Problem + +The `/feedback` command records a log-only `feedback/record` event and acknowledges the user, but the acknowledgement carried no durable context about what happened to the session: deployments that mount session telemetry (`FULL`, `FEEDBACK_ONLY`, or `DISABLED`) had no way to tell the user whether their feedback and session left the process, and the receiving session id was not echoed. The command plugin could not read the sharing policy because the telemetry seam exposed capture only, and the OTel mode enum lived in the optional backend package. + +## Decision + +The telemetry seam (`@deepseek-ai/dsh-session-telemetry`) now owns a backend-independent sharing vocabulary: `TelemetrySharingStatus` (`full` | `feedback-only` | `disabled`) plus a required abstract `sharing` member on the `Telemetry` service class — every backend must disclose its policy, so a consumer renders "not configured" only when no telemetry service is mounted. `@deepseek-ai/dsh-session-telemetry-otel` maps its serialized `TelemetryMode` (the [feedback-gated delivery decision](2026-08-05-feedback-gated-session-telemetry.md) owns the mode semantics) onto that status in the constructor and discloses it, including in `DISABLED`. The `/feedback` handler reads the mounted service through the plugin context (`ctx.get('telemetry')`, never a declared injection, so the command loads and runs without telemetry) and appends one sharing sentence to the acknowledgement: `Feedback recorded for session {id}. `. No service → `Session sharing is not configured.`; `disabled` → `Session sharing is disabled.`; `feedback-only` → `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`; `full` → `Session sharing is enabled.` + +The disclosure states the current sharing policy only; it never promises delivery or retention. Handoff is the backend's non-blocking enqueue and batching, retry, and loss policy stay the backend SDK's, and a later reconfiguration can change what was shared, so the sentences claim nothing about what reached a collector or about future retention. The disclosure adds no session event and never reaches the model surface; the web client renders it through the existing command row (`CommandNode` outcome text) with no client change. + +## Alternatives considered + +**A client-side status RPC and badge.** Rejected because the acknowledgement is host-produced and the web client already renders the command result text verbatim in the command row; a separate RPC would duplicate the status in a second surface and add a wire contract for a sentence. + +**Declared `telemetry` injection in `command-feedback`.** Rejected because telemetry is optional: a declared injection fails plugin load when the service is absent, while the command must work without it. The plugin reads the service with `ctx.get('telemetry')` at handler time instead. + +**OTel package owns the vocabulary.** Rejected because `command-feedback` must not depend on the optional OTel backend package. The seam owns `TelemetrySharingStatus` so any backend can disclose a policy. + +## Consequences + +The acknowledgement is user-visible: it names the receiving session and reports the current sharing policy, honest about the fire-and-forget handoff. Package tests pin the sentence for each status and for the absent-service case; the assembled-browser e2e mounts the shipped telemetry row in FULL mode against a local dead endpoint and pins the shipped default sentence (`Session sharing is enabled.`) as a golden. The seam member is required, so a mounted backend always discloses a policy and the "not configured" sentence truthfully means no telemetry service; the `/feedback` command keeps working with no telemetry mounted. A still-blank web session renders no command row, so feedback recorded before the first message gets no visible acknowledgement (documented under the package README's limitations). diff --git a/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md new file mode 100644 index 0000000000..ac26b18ad5 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-feedback-acknowledgement-sharing-disclosure.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 反馈确认中的会话共享披露 + +Status: implemented + +[English](2026-08-07-feedback-acknowledgement-sharing-disclosure.md) | 中文 + +## 问题 + +`/feedback` 命令会记录一个仅写入日志的 `feedback/record` 事件并确认用户,但确认文本没有携带关于会话去向的持久信息:挂载了会话遥测(`FULL`、`FEEDBACK_ONLY` 或 `DISABLED`)的部署无法告知用户其反馈和会话是否离开了进程,确认文本也没有回显接收会话的 id。命令插件无法读取共享策略,因为遥测 seam 只暴露采集能力,而 OTel 模式枚举位于可选的后端包中。 + +## 决策 + +遥测 seam(`@deepseek-ai/dsh-session-telemetry`)现在拥有与后端无关的共享词汇:`TelemetrySharingStatus`(`full` | `feedback-only` | `disabled`),并在 `Telemetry` 服务类上增加一个必需的抽象 `sharing` 成员——每个后端都必须披露其策略,因此消费方只有在未挂载任何遥测服务时才渲染「未配置」。`@deepseek-ai/dsh-session-telemetry-otel` 在构造函数中把序列化的 `TelemetryMode`(模式语义由[反馈门控投递决策](2026-08-05-feedback-gated-session-telemetry.md)负责)映射到该状态并披露,包括 `DISABLED` 模式。`/feedback` 处理器通过插件上下文读取已挂载的服务(`ctx.get('telemetry')`,绝不是声明的注入,因此命令在无遥测时也能加载和运行),并在确认文本后追加一句共享披露:`Feedback recorded for session {id}. <句子>`。无服务 → `Session sharing is not configured.`;`disabled` → `Session sharing is disabled.`;`feedback-only` → `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`;`full` → `Session sharing is enabled.` + +披露只陈述当前的共享策略,绝不承诺投递或留存:交接是后端的非阻塞入队,批处理、重试与丢失策略仍归后端 SDK,且后续重新配置可能改变已共享的内容,因此句子不声称任何内容已到达采集端,也不声称未来的留存。披露不新增任何会话事件,也绝不会进入模型 surface;Web 客户端通过现有的命令行(`CommandNode` 的结果文本)原样渲染,无需客户端改动。 + +## 备选方案 + +**客户端新增状态 RPC 与徽标。** 拒绝,因为确认文本由宿主生成,Web 客户端已经在命令行中原样渲染命令结果文本;单独的 RPC 会在第二个 surface 重复该状态,并为一句文案新增线上契约。 + +**在 `command-feedback` 中声明 `telemetry` 注入。** 拒绝,因为遥测是可选的:服务缺失时声明注入会导致插件加载失败,而命令必须在无遥测时可用。插件改为在处理器执行时用 `ctx.get('telemetry')` 读取服务。 + +**由 OTel 包拥有词汇。** 拒绝,因为 `command-feedback` 不能依赖可选的 OTel 后端包。seam 拥有 `TelemetrySharingStatus`,任何后端都能披露策略。 + +## 后果 + +确认文本对用户可见:它点名接收会话并报告当前的共享策略,如实说明 fire-and-forget 交接。包级测试为每种状态以及无服务场景固定句子;组装浏览器 e2e 以 FULL 模式挂载随附的遥测行(指向本地 dead 端点),并以 golden 固定随附默认句子(`Session sharing is enabled.`)。seam 成员是必需的,因此已挂载的后端总会披露策略,「未配置」句子如实地表示没有遥测服务;`/feedback` 命令在未挂载遥测时仍能正常工作。仍为空白的新 Web 会话不渲染命令行,因此首条消息之前记录的反馈没有可见确认(已在包 README 的限制中记录)。 diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml new file mode 100644 index 0000000000..0d52923b6e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.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-08-07-workspace-picker-composer-entry.md +2026-08-07-workspace-picker-composer-entry.md: dc9c26c291de6e7614ace3c787030c0032a9740d +2026-08-07-workspace-picker-composer-entry.zh.md: 585c59b12392823a7388ab7a635a864bd4108929 diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md new file mode 100644 index 0000000000..dc9c26c291 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md @@ -0,0 +1,29 @@ +# Agent Note: The no-Workspace composer opens the existing picker + +Status: implemented + +English | [中文](2026-08-07-workspace-picker-composer-entry.zh.md) + +## Problem + +The [session-scope decision](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md) keeps one resident composer before a Workspace exists, but its textarea was disabled and only the smaller Workspace chip could open the picker. The largest and most familiar starting affordance therefore rejected the user's first click even though a recovery action was available on the same surface. + +## Decision + +While no Workspace owns the new Session, the whole composer card activates the existing `conversation.hero.workspace` picker by pointer click — the card owns the click handler and its disabled controls let pointer events fall through, so the full capsule is one target — and the read-only resident textarea does the same by Enter or Space. `aria-haspopup="menu"` and `aria-expanded` describe the shared picker menu while it is mounted. On a fresh installation with no Workspace rows, the picker immediately hands off to the directory dialog and clears its expanded state; that dialog exposes its own accessibility semantics. A dashed l4 stroke (an SVG dash ring, since native `dashed` has a fixed pattern) with a business-blue hover marks the card as the pick affordance. The card contains `pointerdown`, so the open picker's outside-close cannot race the click's reopen — that close-then-open flickered the chip's expansion echo. Message submission, command, permission, model, and other Session-scoped controls remain locked until Workspace selection creates or reconnects a real Session. + +Workspace selection retains the existing owner and flow. `ConversationRoot` opens the picker, `WorkspacePicker` lists or creates the Workspace, and the same textarea DOM node becomes the editable composer after the Session arrives. + +## Alternatives considered + +**Keep the textarea disabled and emphasize the Workspace chip.** This preserves the old control boundary but leaves the dominant composer surface inert during the first action. + +**Place a transparent button over the textarea.** A button has direct trigger semantics, but it creates a second focusable element over the resident textarea and complicates the DOM-identity transition that preserves focus, IME, and draft behavior. + +**Accept a draft before Workspace selection.** This would require a client-owned draft Session or another pre-Session state axis. The feature only needs a discoverable path into the existing picker. + +## Consequences + +The first composer click now continues the required setup flow, and keyboard users can activate the same path. The textarea accurately reports read-only state until a Session exists, while adjacent controls remain disabled. The UI introduces no new Workspace state, transport, or directory-selection flow. + +Component coverage pins pointer and keyboard activation, the card-wide click target, the contained `pointerdown`, locked adjacent controls, picker expansion, and the same-node transition to an editable textarea. The assembled Web helper begins fresh Workspace setup through the textarea, so replayed browser scenarios exercise the shipped path. diff --git a/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md new file mode 100644 index 0000000000..585c59b123 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 未选择 Workspace 时从编辑器打开现有选择器 + +Status: implemented + +[English](2026-08-07-workspace-picker-composer-entry.md) | 中文 + +## 问题 + +[Session scope 决策](../architecture/2026-07-25-web-client-session-scope-and-provide-channel.md)会在 Workspace 存在前保留同一个常驻编辑器,但 textarea 处于禁用状态,只有较小的 Workspace chip 能打开选择器。用户首次点击最显眼、也最熟悉的输入区域时,界面不会响应,尽管同一界面已有继续操作的入口。 + +## 决策 + +新会话尚未归属任何 Workspace 时,整张输入卡片都可通过鼠标点击激活现有的 `conversation.hero.workspace` 选择器——点击处理器归卡片所有,其禁用控件放行指针事件,因此整个胶囊是同一个目标;只读的常驻 textarea 也可经 Enter 或 Space 激活。`aria-haspopup="menu"` 和 `aria-expanded` 在共享选择器菜单挂载时描述其展开状态。全新安装没有 Workspace 行时,选择器会立即转交目录对话框并清除自身的展开状态;该对话框使用自己的可访问性语义。虚线 l4 描边(SVG dash ring,因为原生 `dashed` 的间距不可调)配合 hover 时的 business 蓝,把卡片标记为选择入口。卡片会拦下 `pointerdown`,使已打开选择器的外点关闭无法与点击的重新打开竞态——先关后开会让 chip 的展开回显闪动。消息提交、命令、权限、模型及其他 Session 作用域控件会保持锁定,直到用户选择 Workspace 并创建或重新连接真实 Session。 + +Workspace 选择继续使用现有 owner 和流程。`ConversationRoot` 打开选择器,`WorkspacePicker` 列出或创建 Workspace;Session 到达后,同一个 textarea DOM 节点变为可编辑状态。 + +## 考虑过的替代方案 + +**保持 textarea 禁用并突出 Workspace chip。** 这样能保留原有控件边界,但首次操作时最主要的编辑器区域仍然没有响应。 + +**在 textarea 上方放置透明按钮。** 按钮具备直接的触发器语义,但它会在常驻 textarea 上方增加第二个可聚焦元素,并使保留焦点、输入法和草稿行为的 DOM identity 过渡更复杂。 + +**在选择 Workspace 前接收草稿。** 这需要由 client 拥有的草稿 Session 或另一条 Session 前状态轴。此功能只需要提供一个更容易发现的现有选择器入口。 + +## 后果 + +用户首次点击编辑器即可继续必要的设置流程,键盘用户也能激活同一路径。textarea 会如实报告只读状态,直到 Session 存在;相邻控件仍处于禁用状态。界面没有引入新的 Workspace 状态、传输或目录选择流程。 + +组件测试会固定鼠标和键盘激活、覆盖整卡的点击目标、被拦下的 `pointerdown`、相邻控件锁定、选择器展开,以及同一节点变为可编辑 textarea 的过渡。组装后的 Web helper 会通过 textarea 开始全新 Workspace 设置,因此重放浏览器场景会覆盖实际交付路径。 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml new file mode 100644 index 0000000000..9984adfa0f --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.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-08-08-web-background-task-display.md +2026-08-08-web-background-task-display.md: 558d0e26b3b8da83602296c5fdd0944886d4eab7 +2026-08-08-web-background-task-display.zh.md: 9c9f4643bb08c748381ede6bf585858ca1204ff7 diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md new file mode 100644 index 0000000000..558d0e26b3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md @@ -0,0 +1,136 @@ +# Agent Note: Web background-task display + +Status: implemented + +English | [中文](2026-08-08-web-background-task-display.zh.md) + +## Problem + +`ctx.tasks` already runs every long-lived piece of work the harness starts in the background — `bash`, `pwsh`, `pty-send`, and one-shot background subagents — but its only reader was the model. [`dsh-tool-tasks`](../../../../packages/tasks/tool-tasks/README.md) exposes `task_list`, `task_output`, and `task_kill`, and nothing else observed the registry. + +A human at the Web client therefore could not see that a build was running, could not distinguish a finished task from a stuck one, and could not stop one. The only trace was the `run_in_background` tool card that printed a task id somewhere earlier in the transcript, and that card never updates again. + +The session header was already the place where per-session background activity lives: [`dsh-client-ui-subagent`](../../../../packages/client/ui-subagent/README.md) contributes the subagent catalog to `conversation.session.header.actions`. Placement was settled. What was missing was any channel at all that carried task state to a browser. + +## Decision + +Task state reaches the browser as **one whole-snapshot mux frame per session**, pushed at every registry commit point that changes what that session can see. The client keeps a last-wins mirror; a header action renders it. There is no RPC, no polling, and no client-side staleness bookkeeping. + +This ships the list alone. Per-task streamed output and a human-initiated cancellation are separate phases, and the channel is shaped so neither has to undo it. + +### Wire shape + +One frame in the mux stream: + +```ts ignore-check +| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] } +``` + +`TaskView` is browser-safe and owned by the carrier at [`packages/host/apiproxy/src/api/tasks.ts`](../../../../packages/host/apiproxy/src/api/tasks.ts), alongside the other domain contracts, with its wire schema beside it in `tasks.schema.ts`: + +```ts +import type { TaskId } from '@deepseek-ai/dsh-tasks/brand' + +export interface TaskView { + id: TaskId + kind: string + label: string + status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + detail?: string + startedAt: number + finishedAt?: number +} +``` + +`TaskId` comes from the cordis-free [`@deepseek-ai/dsh-tasks/brand`](../../../../packages/tasks/tasks/src/brand.ts) leaf — the same arrangement as the `@deepseek-ai/dsh-llm/brand` import `api/subagents.ts` already uses, because the `dsh-tasks` root reaches `dsh-agent` and is unreachable from a client program even as a type. Like every other non-root subpath in this workspace, it carries an explicit `tsconfig.base.json` `paths` entry; without one the TypeRT analyzer resolves the specifier to `lib/types/` and rejects the reference as unexported. + +`kind` is `string` on the wire rather than `TaskKind`. The kind map is merge-extensible by producer plugins, so a client build cannot enumerate the closed set; presentation falls through a documented default for an unrecognized kind. + +Three `TaskSnapshot` fields are deliberately absent: `ownerSession` (the frame's `sessionId` already carries it), `reported` (an internal notice-delivery bit with no user meaning), and `outputLimitBytes` (producer-owned model-presentation policy). + +The frame carries a whole snapshot rather than a delta for the reason [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) states for itself: start, kill, settlement, reconnect, and a second browser tab all converge through one authoritative value. A session's task set is single-digit; the frame is small. + +### The task-registry change feed + +`TaskService` owns one observation method: + +```ts ignore-check +abstract onTasksChanged(listener: TasksChangedListener): () => void +``` + +It fires **after** every commit that changes what `list(owner)` returns: registration at the end of `start()`, the `stopping` transition in `kill()`, settlement, and the removal `disposeOwner()` performs. An `undefined` owner means an unowned task changed, and therefore every caller's view changed. + +The listener is owner-granular rather than task-granular. The only consumer pushes whole snapshots, so a per-task record would be discarded on arrival — and a per-task feed cannot express the owner-disposal removal at all without inventing a tombstone status nothing else needs. + +`onTaskDone` is not a subset of this. It delivers the terminal record with the exact owner `Agent` under first-wins semantics that `dsh-tool-tasks` couples to `reported`; `onTasksChanged` is pure observation with no delivery meaning and marks nothing reported. Listener throws are contained and never awaited, matching `onTaskDone`, and each registration is an effect on the calling fiber. + +Service disposal deliberately announces nothing. Every `onTasksChanged` registration is an effect on the registry's own fiber, so the listeners are already gone by the time teardown clears the store; an observer learns the registry left through its own disposal, not through a final empty set. + +### The api-proxy carrier + +`mux()` subscribes `ctx.tasks.onTasksChanged` and pushes `session/tasks`; the subscription baseline rides next to the existing `session/subscribed` control frames, so a reconnecting client is current before it renders. + +Four rules the carrier keeps: + +- **Never resume.** A change push reads `tasks.list(owner)` with the exact `Agent` the listener supplied, which stays correct even while that owner's scope is tearing down and a lookup by id would already miss. The baseline instead reads `ctx.tasks.list(ctx.agents.get(session.id))` — the non-resuming registry read, where a session with no live Agent correctly yields only the unowned tasks. Neither path touches the [`api-remotes` Agent resolver](../../../../packages/api/remotes/src/agent-lookup.ts), which resumes a cold session as a side effect of lookup; listing must never revive a session the user merely scrolled past. +- **Fan out unowned changes.** An `undefined` owner pushes a fresh snapshot to every subscribed session, because unowned tasks are visible to every caller. +- **Stay optional.** The carrier reads `ctx.get('tasks')`. A composition without the registry emits no frames, and the client renders no entry point — the posture `sessionProjections` already has in this file. +- **Say nothing about nothing.** The baseline is pushed only for sessions whose list is non-empty, and an absent key on the client means an empty list. A change that empties a list still pushes `[]`, because that one transition is the only thing the client cannot infer from absence. + +### The client mirror + +`SessionListState` carries `tasksBySession: Readonly>`, owned by `SessionManager` and folded from the frame under last-wins, with an emptied set stored as an absent key so absence and `[]` are one representation. + +It lives on the list mirror rather than on `Session` for three reasons: the header action already reads list state through `useSessions`, nothing needs the pre-instantiation buffering `session/queue` requires (no composer behavior depends on tasks), and a later sidebar indicator gets the data without opening a second channel. + +Two clears keep it honest. On re-subscribe the manager drops the session's mirror — the rule `session/queue` already follows, because a fresh baseline is arriving and this generation sends none for an empty set, so a retained list would survive as a phantom. On `host/session-removed` it drops the mirror again: owner disposal already removed the records registry-side, but that lands on the mux stream while the removal frame rides the host stream, so the two have no relative order. + +### The header action + +[`@deepseek-ai/dsh-client-ui-task`](../../../../packages/client/ui-task/README.md) registers one entry in `conversation.session.header.actions`, ordered after the subagent catalog. Its own README owns the presentation contract; the decisions worth recording here are that the control does not render at all until the session has a task, that the live badge is omitted at zero so a history-only session keeps a quiet entry point, and that settled rows stay visible because a failed task's `detail` is the only place its failure is legible. + +A running one-shot background subagent therefore appears both there and in the subagent catalog. The two answer different questions — the catalog navigates into the child's transcript, this list is the only handle a cancellation can ever attach to — and suppressing `kind: 'subagent'` here would leave the cancellation phase with no entry point for exactly those tasks. + +### What this deliberately does not do + +**No web path calls `ctx.tasks.read()`.** It consumes the single output cursor, so a browser read would silently take bytes the model's `task_output` will never see. This is an invariant worth a test rather than a convention, because the failure is invisible at the call site. + +**No cancellation.** That phase owes a decision the seam does not currently answer: `kill()` marks terminal delivery reported, so a human interrupt written against today's contract would leave the model believing its task is still running. + +**No output watermark on the frame.** The output phase's delta channel is where an anchor field earns its place; one added now would have no reader. + +## Alternatives considered + +**Signal frame plus RPC pull, the subagent-catalog shape.** Push a payload-free `tasks-changed` signal, debounce, then re-read authoritative state over a unary RPC. This is what the subagent catalog does, and the cost is visible in [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts): `catalogInflight` for single-flight, `catalogStale` for a trailing re-pull when a membership frame lands mid-request, `updateCatalogActivity` patching loaded rows in place *and* writing into the in-flight request so a response older than the frame gets overwritten, `parentAvailableOverride` replaying a stale `false`, and a reconnect path re-pulling every open catalog. That apparatus exists because the catalog's authority is split — durable lineage from a projection, liveness sampled at response time — and tasks have no durable half to justify inheriting it. It also fails specifically at the moment the output phase cares about: a task settles, its output stream closes immediately, but status only arrives after debounce plus round-trip, so the UI shows a running task with a dead stream for that window. + +**Popover-scoped polling with no seam change.** Cheapest to build and the only option that avoids touching `TaskService`. It cannot support a resident count on the trigger without a resident poll, and both later phases need a real change feed anyway, so it buys a week and spends it back. + +**A session-projection unit over durable task events.** Projection units fold over committed session events, so this would first require task lifecycle to become durable — `task/started` … `task/settled` as a standalone open/close bracket, with the last [`session/end-seed`](../../../../packages/core/session/src/types.ts) marking any unmatched opener as dead history, exactly as the compaction bracket already does. It is genuinely cheaper on the client: `dsh-tool-todo` shows the whole pattern in a fifteen-line unit, and the existing `session/projection` frames, history-tail block, and persisted checkpoint cache would have carried the data with no new wire surface, no carrier subscription, and no manager state. It was rejected because it buys that with a durable format change in service of a browser list, and because it does not extend to the phase it would most need to: [`spill/`](../../../../packages/spill/README.md) exists precisely so oversized tool output stays out of the log, so streamed task output cannot ride durable events either way. Nothing here forecloses revisiting it if durable task history becomes valuable on its own merits. + +**Reusing `PublicTaskSnapshot` from `dsh-tool-tasks`.** Nearly the right fields, but it belongs to the model-facing control surface. A wire type a browser program imports from a tool package couples client presentation to prompt-facing decisions and drags a host-only package into a client build. + +**Folding tasks into the subagent catalog as one "activity" panel.** One entry point instead of two. Rejected because `SubagentCatalogAction` is already 605 lines whose subject is a durable session-lineage tree including finished children; process-scoped tasks are a second data model with different identity, lifetime, and affordances, and the catalog's lazily-expanded branch, duration, and token contracts would all need rewriting to host them. + +**A host-global task list across every session.** The literal reading of "show all running tasks". Rejected because the registry's authorization fence is per-owner-session, so a global read needs a new access rule, and a global list has no business in a session's header — it would need its own home in the sidebar. Nothing in this design blocks adding it later; the per-session frames are the same data. + +## Testing + +The [web e2e scenario](../../../../apps/web/tests/background-task-list.e2e.ts) is the end-to-end proof and runs keyless: a real `run_in_background` bash call registers with `ctx.tasks`, the header count and row appear with no user interaction, and killing the task through the registry flips the open list to its producer detail. It asserts the whole delivery path rather than any single layer. + +Below it, [`tasks-local`](../../../../packages/tasks/tasks-local/tests/tasks.spec.ts) pins the change feed at all four commit points, its containment of a throwing observer, and its removal on both explicit disposal and fiber teardown; [`api-proxy-tasks`](../../../../packages/host/apiproxy/tests/api-proxy-tasks.spec.ts) pins the baseline-only-when-non-empty rule, the three change pushes, the dropped internal fields, the unowned fan-out, the no-resume guarantee, and the registry-absent composition; and the client suites pin the last-wins fold, the absent-key representation, both clears, and the component's ordering, duration, and dismissal behavior. + +## Consequences + +**A missed commit point leaks rows.** If `disposeOwner()` removal ever stops firing the feed, the client keeps tasks that no longer exist until the session disappears. The whole-snapshot shape makes this recoverable rather than corrupting — the next legitimate change repairs the list — but the disposal path is the one most easily forgotten, so it carries its own test. + +**Unowned-task fan-out is easy to under-implement.** Pushing only to the changed owner's session is correct for owned tasks and silently wrong for unowned ones, which are visible everywhere. The bug would surface only in compositions that create unowned tasks, which is why the carrier suite covers it directly. + +**The UI set is not the registry's set.** The header shows what one session can see, so a task owned by another session never appears in it even though the registry holds it — and because the registry is process-local, a restart empties every list while the transcript still shows the `run_in_background` cards that started them. Unowned tasks are the opposite case: they reach every session's list, exactly as `list(caller)` reports them to every caller. + +**Settled rows accumulate.** The registry retains settled tasks until owner disposal, so a long session with many background commands grows a long list. Capping the settled tail is a presentation change, not a protocol one, if it becomes a real complaint. + +**`stopping` is nearly unreachable today.** Only the model's `task_kill` produces it, so the state is rendered but rarely seen until human cancellation lands. It is in the union now because leaving a status out would have made that phase a wire change. + +**Two entry points for one running subagent.** Accepted deliberately, and bounded to one-shot background delegations. If it reads as noise in practice, the fix is presentational — the catalog row can cite the task rather than the task list hiding the kind. + +**A new non-root subpath needs its `paths` entry.** `@deepseek-ai/dsh-tasks/brand` had to be registered in `tsconfig.base.json` before the TypeRT analyzer would accept the reference. The failure mode is a confusing "not exported by" error from a generator far from the edit, so the entry is part of adding a subpath, not an optimization. diff --git a/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md new file mode 100644 index 0000000000..9c9f4643bb --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md @@ -0,0 +1,136 @@ +# Agent Note: Web 后台任务展示 + +Status: implemented + +[English](2026-08-08-web-background-task-display.md) | 中文 + +## 问题 + +`ctx.tasks` 已经承载了 harness 在后台启动的全部长时工作——`bash`、`pwsh`、`pty-send`,以及一次性后台 subagent——但它唯一的读者是模型。[`dsh-tool-tasks`](../../../../packages/tasks/tool-tasks/README.md) 暴露了 `task_list`、`task_output` 和 `task_kill`,除此之外没有任何东西观察这个注册表。 + +于是 Web 端的人类看不到构建正在跑,分不清一个任务是已经完成还是卡死,也无法把它停掉。唯一的痕迹是 transcript 里更早某处那张打印了 task id 的 `run_in_background` 工具卡片,而那张卡片此后再也不会更新。 + +会话 header 本来就是每会话后台活动的落点:[`dsh-client-ui-subagent`](../../../../packages/client/ui-subagent/README.md) 把 subagent 目录贡献到 `conversation.session.header.actions`。位置没有争议。缺的是任何一条把任务状态送到浏览器的通道。 + +## 决策 + +任务状态以**每会话一帧的整份快照**到达浏览器,在注册表每一个会改变该会话可见内容的提交点推出。客户端保持一份 last-wins 镜像,由一个 header 入口渲染。没有 RPC,没有轮询,客户端不需要任何过期状态管理。 + +本次只交付列表。每个任务的流式输出与人类发起的中断是各自独立的阶段,而通道的形状让两者都不必推翻它。 + +### 线路形状 + +mux 流中的一帧: + +```ts ignore-check +| { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] } +``` + +`TaskView` 是浏览器安全类型,由载体在 [`packages/host/apiproxy/src/api/tasks.ts`](../../../../packages/host/apiproxy/src/api/tasks.ts) 里拥有,与其他领域契约并列,线路 schema 就在旁边的 `tasks.schema.ts`: + +```ts +import type { TaskId } from '@deepseek-ai/dsh-tasks/brand' + +export interface TaskView { + id: TaskId + kind: string + label: string + status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + detail?: string + startedAt: number + finishedAt?: number +} +``` + +`TaskId` 取自不依赖 cordis 的 [`@deepseek-ai/dsh-tasks/brand`](../../../../packages/tasks/tasks/src/brand.ts) 叶子——与 `api/subagents.ts` 已经在用的 `@deepseek-ai/dsh-llm/brand` 导入是同一种安排,因为 `dsh-tasks` 根出口会牵到 `dsh-agent`,即便只作类型也无法被客户端程序触及。和本仓库其他每一个非根子路径一样,它带有显式的 `tsconfig.base.json` `paths` 条目;没有这一条,TypeRT 分析器会把该 specifier 解析到 `lib/types/` 并判定该引用未被导出。 + +线路上的 `kind` 是 `string` 而非 `TaskKind`。kind 映射由生产者插件按声明合并扩展,客户端构建无法枚举这个闭集;遇到无法识别的 kind,呈现层走一条有文档的默认分支。 + +`TaskSnapshot` 的三个字段被刻意省去:`ownerSession`(帧的 `sessionId` 已经带了)、`reported`(内部的通知投递位,对用户无意义),以及 `outputLimitBytes`(生产者拥有的模型呈现策略)。 + +这一帧带整份快照而非增量,理由就是 [`session/queue`](../../../../packages/host/apiproxy/src/api/events.ts) 为自己写下的那条:启动、中断、结算、重连,以及第二个浏览器标签页,全都通过同一个权威值收敛。一个会话的任务集是个位数,帧很小。 + +### 任务注册表变更订阅 + +`TaskService` 拥有一个观察方法: + +```ts ignore-check +abstract onTasksChanged(listener: TasksChangedListener): () => void +``` + +它在每一个会改变 `list(owner)` 返回内容的提交点**之后**触发:`start()` 末尾的注册、`kill()` 里转入 `stopping`、结算,以及 `disposeOwner()` 执行的移除。`owner` 为 `undefined` 表示一个无主任务发生了变化,因而每一个调用方的视图都变了。 + +监听器按 owner 而非按任务分粒度。唯一的消费方推的是整份快照,逐任务记录到手即弃——而且逐任务的订阅根本无法表达 owner 销毁时的移除,除非发明一个别处都不需要的墓碑状态。 + +`onTaskDone` 不是它的子集。后者按 first-wins 语义投递终态记录和确切的 owner `Agent`,`dsh-tool-tasks` 把这套语义与 `reported` 绑在一起;`onTasksChanged` 是纯观察,不含任何投递含义,也不把任何东西标为已上报。监听器抛错被包住且从不 await,与 `onTaskDone` 一致,每次注册都是调用方 fiber 上的 effect。 + +服务销毁刻意什么都不通告。每个 `onTasksChanged` 注册都是注册表自身 fiber 上的 effect,等到 teardown 清空 store 时监听器早已消失;观察者通过自己的销毁而不是一份最终空集来得知注册表离开了。 + +### api-proxy 载体 + +`mux()` 订阅 `ctx.tasks.onTasksChanged` 并推送 `session/tasks`;订阅 baseline 紧挨着既有的 `session/subscribed` 控制帧发出,让重连的客户端在渲染前就是最新的。 + +载体守着四条规则: + +- **绝不 resume。** 变更推送用监听器给出的确切 `Agent` 调 `tasks.list(owner)`,即使该 owner 的 scope 正在拆除、按 id 查找已经查不到,它依然正确。baseline 则读 `ctx.tasks.list(ctx.agents.get(session.id))`——不触发 resume 的注册表读法,没有活体 Agent 的会话正确地只得到无主任务。两条路径都不碰 [`api-remotes` 的 Agent 解析器](../../../../packages/api/remotes/src/agent-lookup.ts),那个解析器会把查询变成复活冷会话的副作用;列个任务不该让用户随手划过的会话活过来。 +- **无主变更要扇出。** `owner` 为 `undefined` 时向每一个已订阅会话推一份新快照,因为无主任务对所有调用方可见。 +- **保持可选。** 载体读 `ctx.get('tasks')`。没有挂注册表的组合不发任何帧,客户端也就不渲染入口——`sessionProjections` 在这个文件里已经是这个姿态。 +- **没有就不说。** baseline 只为列表非空的会话推送,客户端上键缺失即表示空列表。把列表清空的那次变更仍然推 `[]`,因为这一个转换是客户端唯一无法从「缺失」推断出来的东西。 + +### 客户端镜像 + +`SessionListState` 带有 `tasksBySession: Readonly>`,由 `SessionManager` 拥有,按 last-wins 从帧折叠而来;被清空的集合存为缺失的键,使「缺失」与 `[]` 成为同一种表示。 + +它放在列表镜像而不是 `Session` 上,有三个理由:header 入口本来就通过 `useSessions` 读列表状态;没有任何东西需要 `session/queue` 那种实例化前的缓冲(没有 composer 行为依赖任务);将来侧栏加指示器时不必再开第二条通道。 + +两处清理让它保持诚实。重新订阅时 manager 丢弃该会话的镜像——`session/queue` 已经遵循的规则,因为新的 baseline 正在路上,而这一世代对空集不发 baseline,被留下的列表会变成幽灵。`host/session-removed` 时再丢一次:owner 销毁在注册表侧已经移除了记录,但那件事落在 mux 流上而这一帧走 host 流,两者没有相对顺序。 + +### header 入口 + +[`@deepseek-ai/dsh-client-ui-task`](../../../../packages/client/ui-task/README.md) 在 `conversation.session.header.actions` 注册一个条目,排在 subagent 目录之后。呈现契约归它自己的 README;值得记在这里的决策是:会话没有任务时控件根本不渲染;活跃角标为零时省略,让只剩历史的会话保留一个安静的入口;终态行保持可见,因为失败任务的 `detail` 是其失败唯一可读之处。 + +因此一个运行中的一次性后台 subagent 会同时出现在那里和 subagent 目录里。两者回答不同的问题——目录负责进入子会话的 transcript,而这个列表是中断能力唯一可能附着的句柄——在这里屏蔽 `kind: 'subagent'` 会让中断那一期恰好对这批任务没有入口。 + +### 刻意不做的事 + +**没有任何 Web 路径调用 `ctx.tasks.read()`。** 它消费唯一的输出游标,浏览器读一次就悄悄拿走了模型 `task_output` 永远看不到的字节。这该是一条有测试兜底的不变量而不是一条约定,因为它的故障在调用点完全不可见。 + +**不做中断。** 那一期欠一个 seam 目前没有回答的决策:`kill()` 会把终态投递标为已上报,所以照今天的契约写出来的人类中断,会让模型一直以为它的任务还在跑。 + +**帧上不带输出水位。** 输出那一期的增量通道才是锚点字段该出现的地方;现在加就是一个没有读者的字段。 + +## 备选方案 + +**信号帧加 RPC 拉取,即 subagent 目录的形状。** 推一个无 payload 的 `tasks-changed` 信号,防抖后用一元 RPC 重读权威状态。subagent 目录就是这么做的,代价在 [`SessionManager`](../../../../packages/client/runtime/src/client/sessions/manager.ts) 里一览无余:`catalogInflight` 做单飞行、`catalogStale` 在成员帧落于请求中途时补一次尾拉、`updateCatalogActivity` 既就地打补丁又往在途请求里写一份好让比帧更旧的响应被覆盖、`parentAvailableOverride` 重放一个过期的 `false`,还有重连时逐一重拉每个打开的目录。这套装置之所以存在,是因为目录的权威被劈成两半——持久血缘来自投影,活跃度是响应时刻的采样——而任务没有持久的那一半,不该继承这份复杂度。它还恰好在输出那一期最在意的时刻失效:任务结算,输出流立即关闭,状态却要等防抖加一次往返才到,那段窗口里 UI 显示一个流已死的运行中任务。 + +**只在弹层打开时轮询,不改 seam。** 最省事,也是唯一不碰 `TaskService` 的选项。它无法在不常驻轮询的前提下支持触发器上的常驻计数,而后面两期反正都需要一条真正的变更订阅,所以它省下一周又还回去。 + +**基于持久任务事件的 session-projection 单元。** 投影单元在已提交的会话事件上折叠,所以这条路要先让任务生命周期变持久——`task/started` … `task/settled` 作为一对独立的开合括号,由最后一个 [`session/end-seed`](../../../../packages/core/session/src/types.ts) 把未配对的开括号标为死历史,与 compaction 括号已有的做法完全一致。它在客户端确实更省:`dsh-tool-todo` 用十五行的单元展示了整套模式,而现成的 `session/projection` 帧、history-tail 块和持久化 checkpoint 缓存本可以承载这批数据,无需新线路面、无需载体订阅、无需 manager 状态。否决它,是因为这要拿一次持久格式变更去换一个浏览器列表,而且它并不能延伸到最需要它的那一期:[`spill/`](../../../../packages/spill/README.md) 的存在正是为了让超大工具输出留在日志之外,所以流式任务输出无论如何都不能骑在持久事件上。如果持久任务历史将来凭自身价值站得住,本设计不阻挡重新考虑它。 + +**复用 `dsh-tool-tasks` 的 `PublicTaskSnapshot`。** 字段几乎就是对的,但它属于面向模型的控制面。浏览器程序从一个 tool 包导入线路类型,会把客户端呈现耦合到面向 prompt 的决策上,并把一个 host-only 包拖进客户端构建。 + +**并进 subagent 目录做成统一的「活动」面板。** 一个入口而不是两个。否决的理由是 `SubagentCatalogAction` 已经 605 行,其主题是含已结束子会话的持久会话血缘树;进程域的任务是第二套数据模型,身份、生命期和可用动作都不同,而目录的懒展开分支、时长与 token 契约全都要重写才能容纳它们。 + +**跨全部会话的 host 全局任务列表。**「显示所有运行中任务」的字面读法。否决是因为注册表的鉴权围栏是按 owner 会话的,全局读需要一条新的访问规则,而且全局列表不该出现在某个会话的 header 里——它需要侧栏里自己的位置。本设计没有阻挡后续再加;按会话的帧就是同一批数据。 + +## 测试 + +[web e2e 场景](../../../../apps/web/tests/background-task-list.e2e.ts)是端到端的证据,且无需密钥:一次真实的 `run_in_background` bash 调用注册进 `ctx.tasks`,header 的计数与行在没有任何用户操作的情况下出现,通过注册表杀掉该任务后打开着的列表翻到生产者给出的 detail。它断言的是整条投递链路,而不是其中某一层。 + +在它之下,[`tasks-local`](../../../../packages/tasks/tasks-local/tests/tasks.spec.ts) 钉住变更订阅的全部四个提交点、对抛错观察者的包容,以及显式销毁与 fiber 拆除两条路径上的注销;[`api-proxy-tasks`](../../../../packages/host/apiproxy/tests/api-proxy-tasks.spec.ts) 钉住「非空才发 baseline」、三次变更推送、被丢弃的内部字段、无主扇出、不 resume 的保证,以及没有注册表的组合;客户端各套件钉住 last-wins 折叠、缺失键表示、两处清理,以及组件的排序、时长与关闭行为。 + +## 影响 + +**漏掉一个提交点会漏行。** 如果 `disposeOwner()` 的移除有朝一日不再触发订阅,客户端会一直留着已经不存在的任务,直到会话消失。整份快照的形状让这件事可恢复而非损坏——下一次正当变更就修好了——但销毁路径是最容易被忘掉的一条,所以它自带测试。 + +**无主任务的扇出很容易做漏。** 只推给变更 owner 所在的会话,对有主任务是对的,对处处可见的无主任务则是悄悄错的。这个 bug 只会在会创建无主任务的组合里显形,所以载体套件直接覆盖了它。 + +**UI 的集合不等于注册表的集合。** header 显示的是「一个会话能看到什么」,所以别的会话拥有的任务在这里永远不出现,尽管注册表里有它;而由于注册表是进程本地的,一次重启会清空所有列表,transcript 里那些启动它们的 `run_in_background` 卡片却还在。无主任务是反过来的情形:它们会进入每一个会话的列表,正如 `list(caller)` 对每个调用方都报告它们。 + +**终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。 + +**`stopping` 今天几乎不可达。** 只有模型的 `task_kill` 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。 + +**一个运行中的 subagent 有两个入口。** 这是刻意接受的,且被限制在一次性后台委派这一种情况。如果实际用起来读着像噪声,修法是呈现层的——可以让目录行引用那个任务,而不是让任务列表隐藏这个 kind。 + +**新增非根子路径必须补 `paths` 条目。** `@deepseek-ai/dsh-tasks/brand` 得先登记进 `tsconfig.base.json`,TypeRT 分析器才会接受该引用。它的故障表现是一条来自远离改动处的生成器的、令人困惑的「not exported by」错误,所以这个条目是新增子路径的组成部分,而不是优化。 diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml index c9d678da0e..970799a54b 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md -2026-08-08-windows-acl-restricted-token-sandbox.md: 7e8f229269233d9ac9baa65241ca02a4cf4c3f7c -2026-08-08-windows-acl-restricted-token-sandbox.zh.md: eeb346b228b3559f487448e5d4ec525b7bb89525 +2026-08-08-windows-acl-restricted-token-sandbox.md: 972713e02860218853f421aa700a8b60b33ada5b +2026-08-08-windows-acl-restricted-token-sandbox.zh.md: da41cb3f9aa46bab96a5fbb6c035205b22c442aa diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md index 7e8f229269..972713e028 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md @@ -6,15 +6,15 @@ English | [中文](2026-08-08-windows-acl-restricted-token-sandbox.zh.md) ## Problem -The [sandbox decision](2026-07-06-sandbox.md) leaves `PLATFORM_CHAINS.win32` empty, so shipped Windows profiles degrade to danger-full-access because no confining executor exists. The win32 rung must confine the two file-effect modes the sandbox vocabulary promises — `read-only` (zero writes) and `workspace-write` (writes under the workspace root plus a backend-defined temp area) — while leaving reads, network, and process visibility alone, because every mode permits reading. +The original [sandbox decision](2026-07-06-sandbox.md) left `PLATFORM_CHAINS.win32` empty, so shipped Windows profiles degraded to danger-full-access because no confining executor existed. The win32 rung must govern the two file-effect modes in the sandbox vocabulary — `read-only` (no explicit writable root) and `workspace-write` (writes under the workspace root plus a backend-defined temp area) — while reporting any effects its mechanism cannot govern; reads, network, and process visibility remain outside this vocabulary. ## Decision -Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs include a write SID (`S-1-4-x-y`); the write SID's Write ACEs on the workspace and temp roots are the entire write allowlist, because `WRITE_RESTRICTED` intersects write accesses only and reads keep the caller's full ambient access. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on every ignored return value). The write SID is the PER-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`) and stored NOWHERE: the workspace-root ACE therefore materializes once per workspace per machine — the standing ACE is the cross-session reuse cache, and every later provision hits the exact-ACE skip (idempotent re-grant skips the eager full-tree re-propagation — no garbage collection) — instead of once per session, which is what the earlier per-session random SID paid a full tree propagation per session for. The seam derives the session's PRIVATE temp subdirectory from the session id + workspace (sha256, 16 hex — stored nowhere, so no tamper surface exists) and creates it exclusively; it is removed on provider dispose, and a crash leaves it as `%TEMP%` litter whose next resume fails loudly at the exclusive creation until temp hygiene reclaims it. The seam materializes the workspace ACE STANDING (never revoked — the cache) and the temp ACE REVOCABLY (revoked on provider dispose, so an inheritable ACE never outlives its session's temp directory on the ambient temp root). The token's restricting list is the keep-alive group plus the write SID only under workspace-write: read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, write SID]. The keep-alive invariants are logon SID + Everyone (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Read-only carries no write SID: a standing grant ACE from an earlier workspace-write period stays INERT (the pass-2 check grants only what the list carries, so read-only remains strictly zero-grant across a `/permission` downgrade or a crash-resumed session, while the standing ACE keeps the re-upgrade free). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (0x80041003), so CIM is unavailable in every confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both; INTERACTIVE/LOCAL are likewise absent from both (the Public tree writes are denied — pinned by the runner's Public-probe regression). Workspace-write children see a PRIVATE per-session temp subdirectory (`\dsh-<16 hex>` derived from the session id + workspace — created exclusively, reparse points rejected, removed on provider dispose — TMP/TEMP rewritten by the runner — bwrap `--tmpfs /tmp` semantics). The restricted token's DEFAULT DACL is extended with a full-access write-SID ACE (`SetTokenInformation(TokenDefaultDacl)`): new objects created without an explicit security descriptor (anonymous pipes — CreatePipe, sync objects) then carry a restricting-SID ACE and pass the write pass-2 check at creation; NAMED pipes are exempt — their default security descriptor is the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only), which the token cannot influence, so piped stdio capture stays denied for confined grandchildren (the POC-documented boundary, pinned by the runner suite). It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md) (backend plus the `./runner` argv-prefix entry), the `win32` chain rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor; the Windows platform layer re-enables the full permission surface (sandbox/sandbox-policy/permission/approval/fs-sandbox) over the confined pwsh stack. +Implement the rung directly on the raw ACL mechanism: duplicate the caller's token into a `WRITE_RESTRICTED` token (`CreateRestrictedToken` with `WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`) whose restricting SIDs carry distinct workspace and private-temp capabilities. `WRITE_RESTRICTED` intersects write accesses only, so reads keep the caller's ambient access while a write must also match one of these capability ACEs. The mechanism is the one huoyaoyuan/windows-acl-restrict-poc (`10e4dfb`) demonstrates; this port checks every API call and fails closed (the POC fail-opened on ignored failures). The per-workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid` — sha256 → `S-1-4-x-y`); its standing workspace ACE is the cross-session reuse cache, and an exact-ACE skip prevents repeated eager tree propagation. Each live session/workspace pair instead gets a random private temp directory and a domain-separated SID derived from that path (`tempWriteSid`); its ACE is revocable, TMP/TEMP point at that directory, and the token default DACL names the temp SID so newly created temp objects do not acquire the shared workspace capability. A fork therefore cannot write its sibling's temp tree. A fresh provider chooses a new path and SID even for the same resumed session, so crash residue is inert litter rather than a collision or inherited capability; agentless calls create and remove the same shape per invocation. The ambient temp root is never an implicit grant. A workspace equal to or containing the temp root fails before any ACL mutation because its inheritable standing ACE would otherwise reach every private child; the direct API rejects overlap in either direction between a writable root and the actual private temp directory. PowerShell can complete its startup AppLocker probe through this private-temp capability, so `workspace-write` remains FullLanguage absent a host-wide policy; `read-only` cannot create the probe files and conservatively enters ConstrainedLanguage. That split is PowerShell startup behavior, not part of the ACL boundary. The token lists are read-only = [logon SID, Everyone] and workspace-write = [logon SID, Everyone, workspace SID, optional temp SID]. Logon SID + Everyone are keep-alive invariants (early DLL init dies with 0xC0000142 and CNG crashes pwsh with 0xE0434352 without them). Because Everyone remains, an external object granting Everyone write access clears both checks; because NTFS ACLs belong to file objects, a granted workspace hard link also grants an external alias. Rejecting all hard links would reject ordinary pnpm workspaces, so the provider reports `enforcement: 'partial'` and the native suite pins both gaps. Read-only carries no capability SID, so standing workspace ACEs remain inert across a mode downgrade. Authenticated Users is absent from both lists — CIM is unavailable, closing the C:\-root tree-creation escape — and INTERACTIVE/LOCAL are absent, denying Public-tree writes. New anonymous pipes and sync objects inherit the temp SID (or workspace SID when temp is disabled, Everyone under read-only) through `SetTokenInformation(TokenDefaultDacl)`; named pipes keep the Win32 layer's owner/SYSTEM/Admins-full, Everyone/ANONYMOUS-read-only template, so piped grandchild stdio remains denied. It ships as [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md), the `win32` rung of [`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md), and [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) as the confining executor. ## How the restriction works (why no new identity) -The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the orphan-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement. +The identity routes restrict by *who* runs the child; this rung restricts by *token derivation*. An identity route (landstrip's restricted-user, AppContainer) runs the child under a fresh account or container SID that starts with zero ACEs on the host's files — everything, reads included, defaults to denied, and every path the child may touch must then be opened back up by writing ACEs for that identity: the wholesale DACL mutation that disqualified both alternatives. The restricted token keeps the caller's own SID and logon session: [`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) derives a token that adds the restricting SIDs and the `WRITE_RESTRICTED` flag, so Windows performs the access check twice — once against the normal SIDs, once against the restricting SIDs — and grants write-class access only where both checks pass. Reads pass on the normal check alone (the caller's SIDs already carry read access everywhere the caller can read), which is why this rung needs no read grants and no new account; writes must additionally clear the capability-SID check, which only the workspace and temp ACEs satisfy. `DISABLE_MAX_PRIVILEGE | LUA_TOKEN` synthesize the limited-user effect of a fresh account token-side, so even an elevated caller derives a filtered token. The same primitive could restrict reads (`SidsToDisable` turning SIDs deny-only), but a read-restricted token would need per-path read grants — reintroducing exactly the cost the identity routes pay — and the sandbox vocabulary never requires read confinement. ## Alternatives considered @@ -32,11 +32,11 @@ The [landstrip evaluation](../../rejected/feature/2026-07-26-evaluate-landstrip- ## Consequences -Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and the exact Win32 code. Cost: no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing ACE mutations on the granted roots (caller-owned directories; workspace ACEs stand forever by design — the reuse cache, invisible residue when a workspace is renamed — temp ACEs revoked by provider dispose together with the derived private temp directory — a crash leaves both behind and the next resume fails loudly at the exclusive creation until temp hygiene reclaims the directory); grant materialization is an EAGER full-tree propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine by the per-workspace identity; CIM is unavailable in BOTH confined modes (AuthUsers dropped from both lists — the WMI namespace security check fails, and `Get-ComputerInfo` silently returns incomplete results) as the price of closing the C:\-root tree-creation escape in both; FAT-class (non-ACL) targets outside the granted roots remain writable under both modes (no security descriptors to intersect — a legacy residue treated as unsupported, warn-only, documented in the README); NULL-DACL directories are not identity-preserving under a grant+revoke round-trip (documented edge, the POC shares it); `whoami` and token-inspection cmdlets fail under the restricted token (diagnostic noise, documented); and BOTH confined modes run `pwsh` in ConstrainedLanguage mode — the restricted token trips PowerShell's lockdown detection, so `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors while `-f` formatting, property access, and core cmdlets/types keep working, and the language mode cannot be lifted back to FullLanguage from inside — taught to the model in the pwsh tool description and documented in the package README's Known Limitations; BOTH confined modes also deny named-pipe opens — libuv's piped-stdio spawns fail with EPERM (the POC-documented "no output redirection" boundary; inherited/ignored stdio and anonymous pipes work) — documented in the package README's Known Limitations and taught to the model in the pwsh tool description. +Bought: write-only confinement with no new OS floor (`CreateRestrictedToken` predates the mxc releases by two decades), reads/network/process visibility untouched exactly as the mode vocabulary requires, and fail-closed errors carrying the API name and exact Win32 code. Sessions share the intentionally standing workspace capability but not their revocable temp capabilities; restart residue cannot block or authorize a resumed session. Cost: enforcement is structurally partial because Everyone-granted writes and NTFS hard-link aliases cannot be path-confined by this token shape; no read-side or network isolation; console isolation unavailable (hidden-console children die with `STATUS_DLL_INIT_FAILED`; children share the host console); standing workspace ACE mutations (the reuse cache, plus inert residue when a workspace is renamed) and random temp litter after an unclean shutdown until OS hygiene reclaims it; EAGER full-tree workspace propagation (`SetNamedSecurityInfoW` walks every descendant immediately — tens of seconds on large workspaces), paid once per workspace per machine; CIM unavailable in both confined modes (Authenticated Users is absent, closing the C:\-root tree-creation escape); FAT-class non-ACL targets still writable; NULL-DACL directories not identity-preserving under a grant/revoke round trip; `whoami` and token-inspection cmdlets failing under the restricted token; read-only pwsh entering ConstrainedLanguage while workspace-write remains FullLanguage absent host policy; and named-pipe opens remaining denied, so libuv piped-stdio grandchildren fail with EPERM while inherited/ignored stdio and anonymous pipes work. The package README owns these operational limits. ## Testing -The product-visible Windows roster flip is win32-only, so the keyless snapshot fixtures — which must replay on macOS/Linux — cannot cover it; the bundle composition specs ([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts), [`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts)) plus the win32 real-runner suites (`packages/sandbox/sandbox-windows-acl/tests/`, `packages/bash/pwsh-sandbox/tests/`) are the substitute evidence, and the CI Windows lane owns the assembled signal. The grant machinery is pinned cross-platform by `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` (the derived private-temp identity — deterministic per session + workspace, distinct across sessions — one-shot materialization, exclusive temp creation with reparse-point rejection and self-cleanup on failure, clean-restart re-grant of the same derived directory, the standing-vs-revocable lifecycle across dispose and the mode-switch cycle, and the derived-SID argv contract — with the Win32 surface mocked) and on win32 by `workspace-sid.spec.ts` (derivation determinism/shape/distinctness), `grant.spec.ts` (real-DACL materialization: revocable paths revoke on dispose, standing paths survive it), the `acl.spec.ts` idempotent-grant fast-path and standing-ACE-after-dispose contract, the `failure-paths.spec.ts` suspension-orphan regression (AssignProcessToJobObject failure terminates the child), and the `runner.spec.ts` `--write-sid` contract (caller-owned grants, private temp subdir through TMP/TEMP, both-mode CIM-denial probes, the mode-downgrade regression — a standing grant ACE is inert under read-only and effective again on re-upgrade — the ambient-writable Public-probe regression (a C:\Users\Public subdirectory write is denied under both modes), and the ConstrainedLanguage pins in both modes, plus the grandchild-stdio matrix pins — inherited/ignored stdio spawns succeed while piped capture is DENIED in both modes). The runner-failure classification is exit-gated on 127 (a confined command that merely prints the `windows-acl-run:` signature on a non-127 exit is never misclassified as "the command did not run" — pinned in the pwsh-sandbox helper suite). +The product-visible Windows roster flip is win32-only, so keyless snapshots that must replay on macOS/Linux cannot cover it; bundle composition specs plus the win32 real-runner suites are the substitute evidence, and the CI Windows lane owns the assembled signal. `sandbox-local/tests/acl-grants.spec.ts` pins random temp allocation, per-session/workspace reuse, fork/workspace separation, crash-resume non-collision, paired argv SIDs, failure cleanup, and standing-versus-revocable lifecycle with Win32 mocked. On Windows, `workspace-sid.spec.ts` pins workspace/temp derivation and domain separation; `acl.spec.ts` pins real DACL lifecycle; and `runner.spec.ts` pins paired-SID validation, sibling temp denial under a shared workspace SID, per-call agentless temp creation/removal, TMP/TEMP rewriting, mode downgrade, Public denial, Everyone/hard-link partial boundaries, mode-specific PowerShell language behavior, and grandchild stdio. ARM64 and emulated x64 native runs own the architecture-specific acceptance evidence. ## Related diff --git a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md index eeb346b228..da41cb3f9a 100644 --- a/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md +++ b/.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.zh.md @@ -6,15 +6,15 @@ Status: implemented ## Problem -[沙盒决策](2026-07-06-sandbox.md)把 `PLATFORM_CHAINS.win32` 留空,交付的 Windows profile 因为没有可用的隔离执行器而退化为 danger-full-access。win32 档必须实现沙盒词汇表承诺的两个文件效果模式——`read-only`(零写入)与 `workspace-write`(仅工作区根目录加后端定义的临时区域可写)——同时保持读、网络与进程可见性不受影响,因为所有模式都允许读取。 +最初的[沙箱决策](2026-07-06-sandbox.md)将 `PLATFORM_CHAINS.win32` 留空,因此交付的 Windows profile 因不存在隔离执行器而退化为 danger-full-access。win32 档必须约束沙箱词汇表中的两种文件效果模式——`read-only`(不显式授予任何可写根目录)与 `workspace-write`(允许写入工作区根目录及后端定义的临时区域)——并报告其机制无法约束的任何效果;读取、网络与进程可见性仍在这套词汇之外。 ## Decision -直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 中包含写入 SID(`S-1-4-x-y`);工作区与临时目录上写入 SID 的 Write ACE 就是全部写入白名单,因为 `WRITE_RESTRICTED` 只对写访问做交集检查,读保持调用者的完整环境访问。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每一个 API 调用并 fail-closed(POC 因忽略返回值而 fail-open)。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`),且**任何地方都不存储**:工作区根目录 ACE 因此每台机器每个工作区只物化一次——常驻 ACE 就是跨会话复用缓存,此后每次供给都命中精确 ACE 跳过(幂等重授权跳过急切的全树重传播——不做垃圾回收)——而不是每会话一次,这正是先前每会话随机 SID 每个会话都要付一次全树传播的代价。seam 从会话 id + 工作区派生会话的**私有**临时子目录(sha256、16 位 hex——任何地方都不存储,因此不存在篡改面)并独占创建;它在提供方 dispose 时移除,崩溃则把它留作 `%TEMP%` 垃圾,其下一次恢复会在独占创建处大声失败,直到临时目录卫生机制将其回收。seam 把工作区 ACE **常驻**物化(绝不撤销——就是缓存),把临时 ACE **可回收**物化(提供方 dispose(资源释放)时撤销,因此可继承 ACE 不会在环境临时根目录上比其会话的临时目录活得更久)。令牌的 restricting list 是保活组加上仅 workspace-write 下的写入 SID:read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、写入 SID]。保活不变式是登录 SID + Everyone(没有它们,早期 DLL init 会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。Read-only 不含写入 SID:先前 workspace-write 时期留下的常驻授权 ACE 保持**失效**(pass-2 检查只授予列表所携带的内容,因此 read-only 在 `/permission` 降级或崩溃后恢复的会话中始终保持严格零授权,而常驻 ACE 让重新升级保持零成本)。Authenticated Users 在**两种**列表中都缺席——WMI namespace 安全校验失败(0x80041003),因此 CIM 在每一种受限模式下都不可用,且 C:\-root 建树逃逸(驻留的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭;INTERACTIVE/LOCAL 同样在两种列表中都缺席(Public 树的写入被拒绝——由 runner 的 Public-probe 回归钉住)。Workspace-write 子进程看到的是私有的每会话临时子目录(`\dsh-<16 hex>`——由会话 id + 工作区派生、独占创建、拒绝 reparse point、提供方 dispose 时移除——TMP/TEMP 由 runner 重写——bwrap `--tmpfs /tmp` 语义)。受限令牌的**默认 DACL** 被扩展一条写入 SID 全权 ACE(`SetTokenInformation(TokenDefaultDacl)`):此后不带显式安全描述符创建的新对象(匿名管道——CreatePipe、同步对象)自带 restricting SID ACE,创建时的写 pass-2 检查通过;**named pipe 例外**——其默认安全描述符是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读),令牌无法影响,因此受限孙进程的管道 stdio 捕获保持拒绝(POC 记载的边界,由 runner 套件钉住)。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)(后端加 `./runner` argv 前缀入口)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 链档、以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付;Windows 平台层在受限 pwsh 栈之上重新启用完整权限面(sandbox/sandbox-policy/permission/approval/fs-sandbox)。 +直接基于原始 ACL 机制实现该档:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌(`CreateRestrictedToken`,`WRITE_RESTRICTED` + `DISABLE_MAX_PRIVILEGE` + `LUA_TOKEN`),其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。`WRITE_RESTRICTED` 只对写访问做交集检查,因此读取保留调用者的环境访问,而写入还必须匹配这些能力 ACE 之一。该机制来自 huoyaoyuan/windows-acl-restrict-poc(`10e4dfb`)的演示;本移植检查每个 API 调用并 fail-closed(POC 因忽略失败而 fail-open)。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`——sha256 → `S-1-4-x-y`);其常驻工作区 ACE 是跨会话复用缓存,精确 ACE 跳过可避免重复的急切全树传播。每个活跃的会话/工作区对则获得一个随机私有临时目录,以及一个从该路径派生的、经过域分离的 SID(`tempWriteSid`);其 ACE 可回收,TMP/TEMP 指向该目录,令牌默认 DACL 列入该临时 SID,因此新建的临时对象不会获得共享的工作区能力。fork 因此无法写入同级会话的临时目录树。即使恢复的是同一会话,新的提供方也会选择新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力;无 agent(智能体)的调用会逐调用创建并移除同样的形态。环境临时根目录绝不会被隐式授权。如果工作区等于或包含临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的常驻 ACE 会向每个私有子目录授权;直接 API 会拒绝可写根目录与实际私有临时目录在任一方向上的重叠。PowerShell 可借助这项私有临时目录能力完成启动时的 AppLocker 探针,因此在没有主机范围策略时,`workspace-write` 会保持 FullLanguage;`read-only` 无法创建探针文件,会保守地进入 ConstrainedLanguage。这一区别属于 PowerShell 启动行为,不是 ACL 边界的一部分。令牌列表为 read-only = [登录 SID、Everyone],workspace-write = [登录 SID、Everyone、工作区 SID、可选临时 SID]。登录 SID + Everyone 是保活不变式(没有它们,早期 DLL 初始化会以 0xC0000142 死亡,CNG 会让 pwsh 以 0xE0434352 崩溃)。由于 Everyone 仍在列表中,向 Everyone 授予写访问的外部对象会通过两次检查;由于 NTFS ACL 属于文件对象,工作区内获授权的硬链接也会使同一对象的外部别名获得授权。拒绝所有硬链接会让普通 pnpm 工作区不可用,因此提供方报告 `enforcement: 'partial'`,原生套件则钉住这两个缺口。Read-only 不含任何能力 SID,因此常驻工作区 ACE 在模式降级后保持失效。Authenticated Users 在两种列表中都不存在——CIM 不可用,从而关闭 C:\-root 建树逃逸——INTERACTIVE/LOCAL 也不存在,因此 Public 树写入被拒绝。新建匿名管道和同步对象通过 `SetTokenInformation(TokenDefaultDacl)` 继承临时 SID(禁用临时目录时继承工作区 SID,read-only 下继承 Everyone);named pipe 保持 Win32 层 owner/SYSTEM/Admins 全权、Everyone/ANONYMOUS 只读的模板,因此受限孙进程的管道 stdio 仍被拒绝。它以 [`@deepseek-ai/dsh-sandbox-windows-acl`](../../../../packages/sandbox/sandbox-windows-acl/README.md)、[`dsh-sandbox-local`](../../../../packages/sandbox/sandbox-local/README.md) 的 `win32` 档,以及作为隔离执行器的 [`@deepseek-ai/dsh-pwsh-sandbox`](../../../../packages/bash/pwsh-sandbox/README.md) 交付。 ## How the restriction works (why no new identity) -身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过孤儿 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。 +身份路线靠"**谁**在跑子进程"来限制,本档靠"令牌派生"来限制。身份路线(landstrip 的 restricted-user、AppContainer)用全新账户或容器 SID 运行子进程,该身份在宿主的文件上从零条 ACE 开始——一切访问(包括读)默认拒绝,子进程要碰的每条路径都必须事后为那个身份补写 ACE 才能放行:这正是让两个备选方案出局的全盘 DACL 改造。受限令牌保留调用者自己的 SID 与 logon session:[`CreateRestrictedToken`](https://learn.microsoft.com/en-us/windows/win32/api/securitybaseapi/nf-securitybaseapi-createrestrictedtoken) 派生一个加入 restricting SIDs 与 `WRITE_RESTRICTED` 标志的令牌,于是 Windows 做两次访问检查——一次按正常 SID,一次按 restricting SIDs——只有两次都放行,写类访问才被授予。读只凭正常检查即可通过(调用者的 SID 在其可读范围内本来就携带读权限),所以本档不需要任何读授权、也不需要新账户;写还必须额外通过能力 SID 检查,而只有工作区与临时目录的 ACE 能满足它。`DISABLE_MAX_PRIVILEGE | LUA_TOKEN` 在令牌侧合成了新账户的受限用户效果,即使提升过的调用者派生的也是过滤令牌。同一原语其实也能限制读(`SidsToDisable` 把 SID 变为 deny-only),但受限读的令牌需要逐路径的读授权——恰好重新引入身份路线付出的代价——而沙盒词汇表从不要求读隔离。 ## Alternatives considered @@ -32,11 +32,11 @@ AppContainer 令牌没有环境读访问:每个可读路径都必须预先通 ## Consequences -所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致)、fail-closed 错误携带 API 名与精确 Win32 错误码。所失:无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);被授权根目录上有驻留 ACE 改动(目录须为调用者所有;工作区 ACE 按设计永久常驻——复用缓存,工作区改名时成为不可见残留——临时 ACE 由提供方 dispose 连同派生的私有临时目录一起回收——崩溃会把两者都留下,下一次恢复会在独占创建处大声失败,直到临时目录卫生回收该目录);授权物化是急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——在大型工作区上耗时数十秒),因按工作区身份,每台机器每个工作区只付一次;CIM 在**两种**受限模式下都不可用(AuthUsers 从两种列表中被移除——WMI namespace 安全校验失败,`Get-ComputerInfo` 静默返回不完整结果),作为关闭两种模式下 C:\-root 建树逃逸的代价;位于被授权根目录之外的 FAT 类(无 ACL)目标在两种模式下仍可写(没有可做交集的安全描述符——作为历史残留处理:不支持、仅警告、已在 README 中记录);NULL DACL 目录在 grant+revoke 往返下不保持身份(记录在案的边角,POC 亦有此行为);`whoami` 与令牌检查 cmdlet 在受限令牌下失败(诊断噪音,已记录);且**两种**受限模式都以 ConstrainedLanguage 模式运行 `pwsh`——受限令牌触发 PowerShell 的锁定检测,因此 `Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,而 `-f` 格式化、属性访问与核心 cmdlet/类型继续工作,语言模式也无法从内部提升回 FullLanguage——已在 pwsh 工具描述中教给模型,并记录在包 README 的 Known Limitations 中;**两种**受限模式同样拒绝 named-pipe 打开——libuv 的管道 stdio spawn 以 EPERM 失败(POC 记载的“无法重定向输出”边界;继承/忽略的 stdio 与匿名管道可用)——记录在包 README 的 Known Limitations 中,并在 pwsh 工具描述中教给模型。 +所得:仅写隔离、不引入新的 OS 版本下限(`CreateRestrictedToken` 比 mxc 的版本早二十年)、读/网络/进程可见性完全不受影响(与模式词汇表一致),且 fail-closed 错误携带 API 名与精确 Win32 错误码。会话共享有意常驻的工作区能力,但不共享各自可回收的临时能力;重启残留既不能阻塞恢复的会话,也不能向其授权。所失:强制执行在结构上只能是部分的,因为此令牌形态无法把 Everyone 授予的写入与 NTFS 硬链接别名限制在路径边界内;无读侧或网络隔离;控制台隔离不可用(隐藏控制台子进程以 `STATUS_DLL_INIT_FAILED` 死亡;子进程共享宿主控制台);工作区常驻 ACE 改动(复用缓存,以及工作区改名后的失效残留)与异常关闭后遗留的随机临时目录垃圾,直到 OS 卫生机制将其回收;工作区授权采用急切的全树传播(`SetNamedSecurityInfoW` 立即遍历每个后代——大型工作区上耗时数十秒),每台机器每个工作区只付一次;CIM 在两种受限模式下均不可用(Authenticated Users 不存在,从而关闭 C:\-root 建树逃逸);FAT 类无 ACL 目标仍可写;NULL-DACL 目录在 grant/revoke 往返下不保持身份;`whoami` 与令牌检查 cmdlet 在受限令牌下失败;read-only pwsh 会进入 ConstrainedLanguage,而在没有主机策略时 workspace-write 保持 FullLanguage;named pipe 打开仍被拒绝,因此 libuv 管道 stdio 的孙进程以 EPERM 失败,而继承/忽略的 stdio 与匿名管道可用。包 README 负责记录这些运行限制。 ## Testing -产品可见的 Windows 阵容切换仅存在于 win32,而 keyless 快照夹具必须在 macOS/Linux 上可重放,因此无法覆盖它;替代证据是 bundle 组合 spec([`base.spec.ts`](../../../../packages/bundle/base/tests/base.spec.ts)、[`windows-shell.spec.ts`](../../../../apps/cli/tests/windows-shell.spec.ts))加上 win32 真实 runner 套件(`packages/sandbox/sandbox-windows-acl/tests/`、`packages/bash/pwsh-sandbox/tests/`),组装态信号由 CI 的 Windows lane 负责。授权机制在跨平台侧由 `packages/sandbox/sandbox-local/tests/acl-grants.spec.ts` 钉住(派生的私有临时身份——按会话 + 工作区确定性、跨会话相异——一次性物化、独占临时目录创建并拒绝 reparse point、失败时自我清理、干净重启时对同一派生目录的重新授权、dispose 与模式切换循环中的常驻/可回收生命周期,以及派生 SID 的 argv 契约——mock 掉 Win32 表面),win32 侧由 `workspace-sid.spec.ts`(派生的确定性/形态/相异性)、`grant.spec.ts`(真实 DACL 物化:可回收路径在 dispose 时撤销、常驻路径存活)、`acl.spec.ts` 的幂等授权快速路径与 dispose 后常驻 ACE 契约、`failure-paths.spec.ts` 的 suspension-orphan 回归(AssignProcessToJobObject 失败会终止子进程)与 `runner.spec.ts` 的 `--write-sid` 契约(调用者所有目录的授权、经 TMP/TEMP 的私有临时子目录、两种模式下的 CIM 拒绝探针、模式降级回归——驻留授权 ACE 在 read-only 下失效并在重新升级后再度生效——环境可写 Public-probe 回归(对 C:\Users\Public 子目录的写入在两种模式下都会被拒绝),以及两种模式下对 ConstrainedLanguage 的钉定,加上孙进程 stdio 矩阵钉定——继承/忽略的 stdio spawn 成功,而管道捕获在两种模式下都被**拒绝**)钉住。runner 失败分类以 127 退出码为门槛(受限命令仅仅在非 127 退出时打印 `windows-acl-run:` 签名,也绝不会被误分类为"命令未运行"——由 pwsh-sandbox helper 套件钉住)。 +产品可见的 Windows 阵容切换仅存在于 win32,而必须在 macOS/Linux 上可重放的 keyless 快照无法覆盖它;替代证据是 bundle 组合 spec 加上 win32 真实 runner 套件,组装态信号由 CI 的 Windows lane 负责。`sandbox-local/tests/acl-grants.spec.ts` 在 mock Win32 的情况下钉住随机临时目录分配、按会话/工作区复用、fork/工作区分离、崩溃后恢复不冲突、成对 argv SID、失败清理,以及常驻/可回收生命周期。在 Windows 上,`workspace-sid.spec.ts` 钉住工作区/临时目录派生与域分离;`acl.spec.ts` 钉住真实 DACL 生命周期;`runner.spec.ts` 钉住成对 SID 验证、共享工作区 SID 时对同级会话临时目录的拒绝、无 agent 调用的逐调用临时目录创建/移除、TMP/TEMP 重写、模式降级、Public 拒绝、Everyone/硬链接部分边界、按模式区分的 PowerShell 语言行为与孙进程 stdio。ARM64 与模拟 x64 原生运行负责提供架构特定的验收证据。 ## Related diff --git a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.i18n.yaml b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.i18n.yaml new file mode 100644 index 0000000000..65e73bdff8 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.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-08-09-parallel-subagent-delegations.md +2026-08-09-parallel-subagent-delegations.md: 01dc2043c9a79666857c4f5aa988fc45dc7cdf56 +2026-08-09-parallel-subagent-delegations.zh.md: 2c2bd700e362000b41adbd1f3cec9ba26ff60b93 diff --git a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md new file mode 100644 index 0000000000..01dc2043c9 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md @@ -0,0 +1,47 @@ +# Agent Note: Parallel subagent delegations + +Status: implemented + +English | [中文](2026-08-09-parallel-subagent-delegations.zh.md) + +## Problem + +A model that wants fan-out batches several `subagent` calls into one assistant message — that batch is the parallel intent. The delegation tool declared no `isConcurrencySafe` classifier, so the fail-closed scheduler ([parallel tool-call Agent Note](2026-07-10-parallel-tool-call-execution.md)) treated every foreground delegation as an exclusive barrier: nine cards in the GUI, one child running, eight queued behind it for its full runtime. + +The original conservative stance — a unary classifier cannot prove that sibling delegations have disjoint workspace effects — had stopped protecting anything. `run_in_background: true` and continuable delegations already overlap with every later call, including writes; `dsh-workflow-workerthread` already runs up to its concurrency ceiling of children through the same `ctx.subagents.start()` providers against the shared workspace. Only the foreground variant was serialized. + +## Decision + +`dsh-tool-subagent` declares `isConcurrencySafe: () => true` for every call form (foreground, one-shot background, continuable), so sibling delegations in one assistant step overlap under the loop's rolling pool up to `maxParallelToolCalls`, with results still committed in model order. + +The declaration satisfies the scheduler's safety contract structurally: a child works in its own session, a run never mutates the parent session (the start-time appends — `sandbox/mode`, `approval/policy`, `subagent/descriptor` — land only in the child's own log), and the tool returns its outputs to the loop for ordered commit. The one-shot background form's one parent-owned write is registering a Task through `tasks.start` — a synchronous, commutative insertion that satisfies the scheduler note's shared-state clause rather than the stronger no-mutation property. The provider seam requires concurrent starts and continuable preparations for distinct children to isolate operation-local state, cancellation, settlement, and cleanup. The bundled providers satisfy that contract: spawn and fork keep no mutable state between starts, fork reads only the parent's completed-turn prefix, out-of-process providers allocate state per run, and the continuation manager reserves a unique child identity and lock for each preparation. + +Coordinating sibling workspace effects is the model's responsibility, the stance the product already takes for background, continuable, and workflow children. Peer harnesses agree: Claude Code's Task tool is unconditionally concurrency-safe (cap 10), oh-my-pi's task tool defaults to its overlapping `shared` class, opencode's task tool runs unbounded under its SDK, and Codex sidesteps the question by making delegation an asynchronous spawn/wait mailbox. + +Capacity stays where the scheduler note put it: `maxParallelToolCalls` caps one step's unsettled tool calls — and therefore concurrently running foreground children — while background and continuable calls settle at start and free their pool slot, so children they leave running are not capped by it. LLM providers own their own capacity controls. + +## Testing + +Package tests pin the classifier for both call forms. A gate test drives the registry directly with two children that each block until both have started, proving the half the declaration depends on: the tool body and provider start path tolerate concurrent dispatch — hidden serialization in that stack would deadlock instead of passing silently. A continuable gate holds two provider preparations at the same await, cancels one caller before publication, and proves that the cancelled child leaves no Agent or durable Session while its sibling reaches inbox acceptance and persists independently. The scheduling half, classification actually producing overlap, is owned by the classifier pin and the snapshot below. + +The authored `subagent-parallel` snapshot pins the assembled-app transcript: one assistant message carries two subagent calls, the parent log records `tool/call, tool/call, tool/result, tool/result` (serial execution would interleave call/result pairs), and both children complete as separate sessions. Its twin delegations are deliberately identical: `dsh-llm-replay` binds child scripts by first-call order and the harvester orders children by `createdAt`, and neither is deterministic across concurrent children (the `XXX(concurrent-subagents)` marker), so only interchangeable twins replay race-free today. + +## Alternatives considered + +**Keep delegations exclusive.** The status quo protected nothing: background and workflow children already overlap freely with writes, so serializing the foreground variant only added latency and contradicted the model's explicit batching intent. + +**An input-sensitive classifier.** The call's arguments are a free-text description and prompt; nothing in them distinguishes a safe delegation from an unsafe one, so a conditional classifier would be theater. + +**A Codex-style asynchronous spawn/wait redesign.** Continuable children plus `send_message` already provide the asynchronous channel; rebuilding the foreground contract around a mailbox would discard a working synchronous result path to solve a scheduling problem one declaration fixes. + +**A per-instance `concurrencySafe` config knob.** No consumer needs a serial deployment: `maxParallelToolCalls: 1` already restores global serial execution, and peer-harness prior art defaults delegation to concurrency-safe. + +## Consequences + +Sibling children can race on shared workspace or external resources; the model owns that coordination, as it already does for every other overlapping child. Concurrent children also compete for LLM provider quota; `maxParallelToolCalls` caps only unsettled calls, not children a background or continuable call left running. + +Two one-shot background delegations in one message acquire their model-visible task ids (`subagent-`) in dispatch-race order. The ids are logged, so replay stays valid, but a snapshot scenario that distinguishes its background children would inherit the same determinism constraint as twin child sessions. + +Ordered commits may hold a fast child's result behind a slow earlier sibling — the trade the [scheduler note](2026-07-10-parallel-tool-call-execution.md) already accepted; live surfaces still show each child's own progress. + +A concurrent-children snapshot scenario with distinct prompts still needs replay-harness support (deterministic child-script binding and harvest ordering); until then such scenarios must use interchangeable twin delegations. diff --git a/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md new file mode 100644 index 0000000000..2c2bd700e3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.zh.md @@ -0,0 +1,47 @@ +# Agent Note: 并行 subagent 委派 + +Status: implemented + +[English](2026-08-09-parallel-subagent-delegations.md) | 中文 + +## 问题 + +想要扇出的模型会把多个 `subagent` 调用合并进同一条 assistant 消息:这个批次本身就是并行意图。委派工具此前没有声明 `isConcurrencySafe` 分类器,按安全侧原则设计的调度器([并行工具调用 Agent Note](2026-07-10-parallel-tool-call-execution.md))便把每个前台委派都当作独占屏障:GUI 里显示九张卡片,却只有一个子 agent(智能体)在运行,其余八个要在它的整个运行期间排在其后等待。 + +最初的保守立场(一元分类器无法证明同级委派的工作区效果互不相交)已经不再保护任何东西:`run_in_background: true` 和可继续委派本来就会与其后的每个调用重叠执行,包括写入;`dsh-workflow-workerthread` 也早已通过同样的 `ctx.subagents.start()` 提供方在共享工作区上并发运行子 agent,数量可达其并发上限。只有前台形态被串行化。 + +## 决策 + +`dsh-tool-subagent` 为每种调用形态(前台、一次性后台、可继续)都声明 `isConcurrencySafe: () => true`,因此同一 assistant 步骤中的同级委派会在循环的滚动池下重叠执行,上限为 `maxParallelToolCalls`,结果仍按模型顺序提交。 + +该声明在结构上满足调度器的安全约定:子 agent 在自己的会话中工作,运行绝不变更父会话(启动时追加的 `sandbox/mode`、`approval/policy`、`subagent/descriptor` 只落在子 agent 自己的日志里),工具把输出返回给循环,由循环按顺序提交。一次性后台形态对父级拥有状态的唯一写入是通过 `tasks.start` 注册一个 Task——这是一次同步、可交换的插入,满足的是调度器 Agent Note 中的共享状态条款,而非更强的「无变更」性质。提供方 seam 要求针对不同子 agent 的并发启动和可继续准备分别隔离操作局部状态、取消、结算和清理。内置提供方满足这项约定:spawn 和 fork 在各次启动之间不保留可变状态,fork 只读取父级已完成轮次的前缀,进程外提供方按每次运行分配状态,继续执行管理器则为每次准备预留唯一的子 agent 身份和锁。 + +协调同级工作区效果是模型的职责,产品对后台、可继续和工作流子 agent 已经采取同样的立场。同类 harness 的做法一致:Claude Code 的 Task 工具无条件并发安全(上限 10);oh-my-pi 的 task 工具默认归入其可重叠的 `shared` 类别;opencode 的 task 工具在其 SDK 下不设上限地运行;Codex 则把委派做成异步 spawn/wait 信箱,绕开了这个问题。 + +容量控制仍保持在调度器 Agent Note 所定的位置:`maxParallelToolCalls` 限制单个步骤中未结算的工具调用数量——因而也限制并发运行的前台子 agent 数量——而后台和可继续调用在启动时即结算并释放池位,它们留下运行的子 agent 不受该上限约束。LLM(大语言模型)提供方负责自身的容量控制。 + +## 测试 + +包测试固定了两种调用形态的分类器。一个门控测试直接驱动注册表,其两个子 agent 各自阻塞,直到两者都已启动,以此证明该声明所依赖的那一半:工具体和提供方启动路径能容忍并发分发——这条栈中任何隐藏的串行化都会造成死锁,而不是静默通过。一个可继续门控测试让两项提供方准备停在同一个 await 上,在发布前取消其中一个调用方,并证明已取消的子 agent 不会留下 agent 或持久会话,而其同级则到达 inbox 接受状态并独立持久化。另一半(分类真正产生重叠执行)由分类器 pin 测试和下述快照负责。 + +人工编写的 `subagent-parallel` 快照固定了组装后应用的 transcript(文本记录):一条 assistant 消息携带两个 subagent 调用,父级日志记录为 `tool/call, tool/call, tool/result, tool/result`(串行执行会让调用/结果成对交错出现),两个子 agent 各自作为独立会话完成。其中的孪生委派刻意做成完全相同:`dsh-llm-replay` 按首次调用顺序绑定子脚本,harvester 按 `createdAt` 对子 agent 排序,二者在并发子 agent 之间都不具确定性(即 `XXX(concurrent-subagents)` 标记),因此目前只有可互换的孪生委派才能无竞态地回放。 + +## 备选方案 + +**保持委派独占。** 现状没有保护任何东西:后台和工作流子 agent 本来就可以带着写入自由重叠,串行化前台形态只会增加延迟,还违背模型显式表达的批量意图。 + +**使用输入敏感的分类器。** 该调用的参数只有自由文本的描述和提示词;其中没有任何内容能区分安全委派与不安全委派,因此条件式分类器只会流于形式。 + +**按 Codex 风格重新设计为异步 spawn/wait。** 可继续子 agent 加上 `send_message` 已经提供了异步通道;围绕信箱重建前台约定,等于为了解决一条声明就能修复的调度问题,丢弃一条可用的同步结果路径。 + +**按实例提供 `concurrencySafe` 配置开关。** 没有消费方需要串行部署:`maxParallelToolCalls: 1` 已能恢复全局串行执行,同类 harness 的先例也默认委派并发安全。 + +## 影响 + +同级子 agent 可能在共享工作区或外部资源上发生竞态;这项协调由模型负责,正如模型对其他所有重叠子 agent 已经承担的那样。并发子 agent 还会争用 LLM 提供方配额;`maxParallelToolCalls` 只限制未结算的调用,不限制后台或可继续调用留下运行的子 agent。 + +同一条消息中的两个一次性后台委派按分发竞态顺序获得各自模型可见的 task id(`subagent-`)。这些 id 已被记录,因此回放仍然有效;但需要区分后台子 agent 的快照场景会继承与孪生子会话相同的确定性约束。 + +有序提交可能让快速子 agent 的结果排在更早的缓慢同级之后等待,这是[调度器 Agent Note](2026-07-10-parallel-tool-call-execution.md)已经接受的取舍;实时界面仍会展示每个子 agent 各自的进度。 + +使用不同提示词的并发子 agent 快照场景仍需要回放 harness 的支持(确定性的子脚本绑定与收集排序);在此之前,此类场景必须使用可互换的孪生委派。 diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.i18n.yaml new file mode 100644 index 0000000000..ac90a1c70d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.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-08-10-continuable-subagent-policy-inheritance.md +2026-08-10-continuable-subagent-policy-inheritance.md: c9b75f2840eb2f124f040d138b761ee145fc6f83 +2026-08-10-continuable-subagent-policy-inheritance.zh.md: 8bd7f68c578ed827c756a415f017eb8cb61e5721 diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md new file mode 100644 index 0000000000..c9b75f2840 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md @@ -0,0 +1,29 @@ +# Agent Note: Continuable subagent policy inheritance — the durable child log owns the delegation-time snapshot + +Status: implemented + +English | [中文](2026-08-10-continuable-subagent-policy-inheritance.zh.md) + +## Problem + +The one-shot in-process driver has seeded parent sandbox/approval overrides into its children since the [in-process policy-inheritance decision](2026-07-25-subagent-policy-inheritance.md), but the continuable path never did: `SubagentContinuationManager` materialization applied only child composition and the activation setup registry. The default bundle wires both delegation tools as `backgroundMode: continuable`, so in a default deployment every background child silently fell back to deployment defaults — a parent switched to `danger-full-access` produced children stuck at `workspace-write` whose every out-of-workspace operation raised an approval prompt, and a parent's unattended `'never'` approval stance reverted to prompting ([dsh-external/issues#334](https://github.com/dsh-external/issues/issues/334)). + +## Decision + +The capture/append pair moved from the one-shot driver into the seam's shared child-agent module (`dsh-subagent/src/child-agent.ts`), the declared one home for shared child composition: `captureDelegatedPolicyOverrides(parent)` snapshots `sandboxPolicy.overrideOf(parent.session)` through optional `ctx.get` and pins the child approval policy to `'never'` ([approvals-pinned decision](2026-08-10-subagent-approval-pinned-never.md)), and `appendDelegatedPolicyOverrides(childSession, overrides)` appends the `source: 'delegation'` events. The one-shot driver and the continuation manager both call them, so the two paths cannot drift. + +`startContinuable` captures before its first await (`prepareContinuable`), the same "a later parent switch belongs to the parent's future" boundary as one-shot. The snapshot travels in `MaterializeInputs.create`, so only fresh materialization appends the events during unpublished setup, after any fork seed. A cold resume passes no `create` inputs and appends nothing: the persisted child log already carries the delegation events, and replaying the log IS the state. The durable child log — not the current Activation, not the resuming parent — owns the child's effective policy, so a parent switch between residency epochs never retroactively changes a durable child. + +## Alternatives considered + +- **An activation-setup-registry contribution** (`registerContinuableSetup`) — rejected: a contribution receives only the child context, so it cannot capture the parent's overrides at the delegation boundary; the registry applies on cold resume as well as fresh creation, which would re-append or re-capture; and nothing ties a contribution's capture to the start call's synchronous prefix, so the pre-await capture guarantee would be lost. +- **Re-capturing the parent's overrides at cold resume** — rejected: a resumed child would silently change policy with the parent's later switches, breaking the snapshot-at-delegation semantic and making effective policy depend on resume timing instead of the child's own log. A parent that wants a resumed child under new policy re-delegates. +- **Importing the one-shot driver's inline logic from the continuation manager** — rejected: the Service Definition package cannot depend on its own provider package, and duplicating the capture/append pair in `continuation.ts` invites drift; `child-agent.ts` already holds every other shared composition step. +- **Seeding the events into the descriptor seed turn** — rejected: the capture value is not known when the seed is assembled for every caller, and the one-shot precedent already establishes unpublished-setup appends as the ordering that places inherited facts after fork history with `firstLiveSeq` intact. + +## Consequences + +- Default-bundle background delegation (`backgroundMode: continuable`) now inherits a parent's explicit sandbox override and pins the child to `'never'` approvals; compositions without either policy service behave unchanged. +- `dsh-subagent` gains optional peer types on `dsh-sandbox-policy` and `dsh-user-approval` (the `ctx.get` pattern the one-shot driver used); `dsh-subagent-inprocess` drops its policy-service peers and type imports entirely and delegates to the shared helpers. +- The continuable suite (`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`) pins fresh-start seeding, pre-await capture, default omission, cold-resume snapshot stability, and fork-seed precedence; the ACP snapshot scenario `subagent-continuable-inheritance` pins the child's delegation event and read-only runtime context through the assembled app and fails when the capture is removed. +- Out-of-process providers (`acp`, `dsh-sdk`, `claude-code`, `codex`) support no continuable children (`prepareContinuable` absent), and their one-shot children keep their own deployment policy (`inheritsParentContext = false`); cross-process policy propagation remains out of scope. diff --git a/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md new file mode 100644 index 0000000000..8bd7f68c57 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.zh.md @@ -0,0 +1,29 @@ +# Agent Note: 可继续 subagent 策略继承——持久化子日志拥有委派时快照 + +Status: implemented + +[English](2026-08-10-continuable-subagent-policy-inheritance.md) | 中文 + +## 问题 + +自[进程内策略继承决策](2026-07-25-subagent-policy-inheritance.md)以来,一次性进程内驱动器一直会把父级的沙箱/审批覆盖项注入其子级,但可继续路径从未这样做:`SubagentContinuationManager` 的物化只应用子级组合与 Activation(激活)设置注册表。默认组合包把两个委派工具都配置为 `backgroundMode: continuable`,因此在默认部署中,每个后台子 agent(智能体)都静默回退到部署默认值:切换到 `danger-full-access` 的父级产出的子 agent 卡在 `workspace-write`,每次工作区外操作都会触发审批提示;父级无人值守的 `'never'` 审批立场也退回为发起提示的行为([dsh-external/issues#334](https://github.com/dsh-external/issues/issues/334))。 + +## 决策 + +捕获/追加这对函数从一次性驱动器移入该 seam 的共享子 agent 模块(`dsh-subagent/src/child-agent.ts`),即声明的共享子级组合唯一归属之处:`captureDelegatedPolicyOverrides(parent)` 通过可选的 `ctx.get` 对 `sandboxPolicy.overrideOf(parent.session)` 建立快照,并把子级审批策略钉定为 `'never'`([审批钉定决策](2026-08-10-subagent-approval-pinned-never.md)),`appendDelegatedPolicyOverrides(childSession, overrides)` 则追加 `source: 'delegation'` 事件。一次性驱动器与继续执行管理器都调用它们,因此两条路径不会出现偏差。 + +`startContinuable` 在其第一次 await(`prepareContinuable`)之前完成捕获,沿用与一次性路径相同的「父级后续切换属于父级的未来」边界。快照放在 `MaterializeInputs.create` 中传递,因此只有全新物化会在未发布的设置阶段、排在任何 fork 种子之后追加这些事件。冷恢复(cold resume)不传入 `create` 输入,也不追加任何内容:持久化的子日志已经携带委派事件,而回放该日志本身就是状态。子 agent 的生效策略由持久化子日志拥有,而不是当前 Activation,也不是发起恢复的父级,因此父级在驻留纪元(residency epoch)之间的切换绝不会追溯性地改变一个持久化子 agent。 + +## 考虑过的替代方案 + +- **一项 Activation 设置注册表贡献**(`registerContinuableSetup`):不予采纳。贡献只接收子级上下文,因此无法在委派边界捕获父级的覆盖项;该注册表在冷恢复与全新创建时都会应用,会导致重复追加或重复捕获;而且没有任何机制把贡献的捕获绑定到 start 调用的同步前缀,await 前捕获的保证会因此丢失。 +- **在冷恢复时重新捕获父级覆盖项**:不予采纳。恢复的子 agent 会随父级后续切换静默改变策略,这会破坏委派时快照的语义,并让生效策略取决于恢复时机而非子级自身的日志。希望恢复的子 agent 采用新策略的父级应重新委派。 +- **让继续执行管理器导入一次性驱动器的内联逻辑**:不予采纳。Service Definition 包不能依赖自己的提供方包,而在 `continuation.ts` 中复制捕获/追加这对函数会招致偏差;`child-agent.ts` 已经承载其余每个共享组合步骤。 +- **把这些事件写入描述符种子轮次**:不予采纳。种子为每个调用方组装时,捕获值尚不可知;而且一次性路径的先例已经确立:在未发布的设置阶段追加,才是把继承事实排在 fork 历史之后、同时保持 `firstLiveSeq` 不变的顺序。 + +## 后果 + +- 默认组合包的后台委派(`backgroundMode: continuable`)现在会继承父级显式的沙箱覆盖项,并把子级钉定为 `'never'` 审批;未组合任一策略服务的组合保持原有行为。 +- `dsh-subagent` 新增针对 `dsh-sandbox-policy` 与 `dsh-user-approval` 的可选 peer 类型(即一次性驱动器所用的 `ctx.get` 模式);`dsh-subagent-inprocess` 完全移除自己的策略服务 peer 与类型导入,委托给共享辅助函数。 +- 可继续测试套件(`packages/subagent/subagent/tests/continuation-inheritance.spec.ts`)锁定全新启动的种子写入、await 前捕获、默认值省略、冷恢复快照稳定性与 fork 种子优先级;ACP 快照场景 `subagent-continuable-inheritance` 经组装后的应用锁定子级的委派事件与只读运行时上下文,移除捕获时即失败。 +- 进程外提供方(`acp`、`dsh-sdk`、`claude-code`、`codex`)不支持可继续子 agent(没有 `prepareContinuable`),其一次性子 agent 保留自身的部署策略(`inheritsParentContext = false`);跨进程策略传播仍不在范围内。 diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml new file mode 100644 index 0000000000..08233cc4f0 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.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-08-10-creator-guidance-introduce-cue.md +2026-08-10-creator-guidance-introduce-cue.md: 888fee7b3def585ed3098fedcb7bc6169ee26a22 +2026-08-10-creator-guidance-introduce-cue.zh.md: d80260abd1995df1f95e3f24fefcb265bda64c11 diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md new file mode 100644 index 0000000000..888fee7b3d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md @@ -0,0 +1,33 @@ +# Agent Note: Creator guidance lands as an introduce cue on the preset chip + +Status: implemented + +English | [中文](2026-08-10-creator-guidance-introduce-cue.zh.md) + +## Problem + +Authoring a preset happens inside a Creator-mode session, but the settings section gave no path into that fact. The creator entry sat outside the roster groups, the custom group vanished entirely while it had no member, and clicking the entry dropped the user onto the new-session screen with nothing marking what had changed: the staged preset chip rendered exactly as if the user had picked it by hand. Users reported not understanding that the flow had moved, or that the session they were about to start was the place where the preset gets built (#2184). + +## Decision + +The custom group stays on screen while empty — heading plus the creator entry, which lives inside the group as the standing "your preset will appear here" affordance rather than floating below the roster. + +A pick staged from another screen carries a one-shot `introduce` flag through the seat store (`stage(id, introduce)`), and the chip announces it: the preset icon eases in over 150ms, then the name's characters fade up on a stagger the moment the icon lands. The stagger is capped twice — 40ms per tick for short CJK names, and one shared 200ms reveal window (`min(40, 200/(n-1))`) so a long Latin name finishes in the same time as its CJK counterpart instead of dragging the run out per character. CSS owns the motion; the component arms it and acknowledges the cue once the run is over, so the flag never replays on a later mount. `prefers-reduced-motion` and an empty display name acknowledge immediately with no run. + +The cue is pure presentation: it is client-side seat-store state, never a session event, because the model-visible composition is already carried by the staged preset itself. + +## Alternatives considered + +**A toast or callout on the new-session screen.** It explains more, but it points at nothing — the chip is the artifact the user must find again later, and a dismissable box teaches the box, not the control. The cue puts the motion on the control itself. + +**A fixed per-character tick.** The first implementation used 60ms per character unconditionally; an English preset name took over three times as long as its four-character Chinese counterpart, reading as lag rather than emphasis. The shared reveal window makes duration a property of the cue, not of the locale. + +**Animating the pick inside the settings dialog before leaving.** The dialog closes as part of the gesture — leaving settings is how the flow says the work happens in the session — so anything played there would be cut off or would delay the navigation it exists to explain. + +## Consequences + +The intro timeline lives in two places that must agree: the component's `INTRO_TEXT_DELAY_MS` and the `.introIcon` CSS animation duration. The component's constants are the source of the character delays and the acknowledgement timeout; the CSS comment names the coupling. The seat store gains one bit of UI state (`introduce`) that every stage decides explicitly, and the section keeps rendering a group with no members — a shape the section golden and unit tests now pin. + +## Testing + +Component tests pin the capped stagger (11-character Latin name at 20ms steps, 4-character CJK name at the 40ms tick, single character with no stagger), the acknowledgement timing, and the reduced-motion and empty-name skips. `apply.spec.ts` drives the cross-screen stage end to end: the creator draft stages with the cue set, one acknowledgement clears it, and a repeat acknowledgement leaves the snapshot untouched. The `agent-preset-authoring` web e2e holds the empty custom group (heading plus creator entry) in its goldens. diff --git a/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md new file mode 100644 index 0000000000..d80260abd1 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 创造模式引导以介绍动效落在预设 chip 上 + +Status: implemented + +[English](2026-08-10-creator-guidance-introduce-cue.md) | 中文 + +## 问题 + +预设的创作发生在创造模式 session 内部,但设置分区没有把这条路径讲清楚。创建入口游离在名册分组之外;自定义分组在没有成员时整个消失;点击入口后用户被抛到新会话屏幕,没有任何标记说明发生了什么变化:暂存的预设 chip 渲染得和用户亲手挑选时一模一样。用户反馈看不懂流程已经移动,也不明白即将开始的 session 正是构建预设的地方(#2184)。 + +## 决定 + +自定义分组在空的时候也常驻屏幕——分组标题加创建入口,入口移入分组内部,作为"你的预设会出现在这里"的常设指引,而不是漂在名册下方。 + +从另一屏幕暂存的选择会经由 seat store 携带一次性的 `introduce` 标志(`stage(id, introduce)`),chip 据此自我介绍:预设图标在 150ms 内缓入,落定的瞬间名称逐字符错峰浮现。错峰有两重上限——短的中文名按每字符 40ms 的节拍,同时共享一个 200ms 的整体揭示窗口(`min(40, 200/(n-1))`),让长的拉丁名与中文名在相同时间内完成,而不是按字符数拖长整轮动画。动效由 CSS 负责;组件只负责触发,并在一轮结束后确认该提示,因此标志不会在后续挂载时重放。`prefers-reduced-motion` 与空显示名会立即确认、不播放动画。 + +该提示纯属呈现层:它是客户端 seat-store 状态,永远不是 session 事件,因为模型可见的组合已由暂存的预设本身承载。 + +## 曾考虑的替代方案 + +**在新会话屏幕上弹 toast 或提示框。** 它能解释更多,但什么也没指向——chip 才是用户之后必须再次找到的对象,可关闭的提示框教会的是提示框本身,不是控件。介绍动效把动作放在控件本体上。 + +**固定的每字符节拍。** 第一版实现无条件使用每字符 60ms;英文预设名的时长超过四字中文名的三倍,读起来像卡顿而非强调。共享揭示窗口让时长成为提示的属性,而不是语言的属性。 + +**离开前在设置对话框内播放选中动画。** 关闭对话框本身就是这个手势的一部分——离开设置正是流程在表达"工作发生在 session 里"——在那里播放的任何内容要么被截断,要么会拖延它本要解释的跳转。 + +## 后果 + +介绍时间线存在于两处且必须一致:组件的 `INTRO_TEXT_DELAY_MS` 与 `.introIcon` 的 CSS 动画时长。组件常量是字符延迟与确认超时的来源;CSS 注释点明了这层耦合。seat store 多出一位 UI 状态(`introduce`),每次暂存都显式决定它;分区则会渲染没有成员的分组——这一形态现由分区 golden 与单元测试钉住。 + +## 测试 + +组件测试钉住带上限的错峰(11 字符拉丁名走 20ms 步进、4 字中文名走 40ms 节拍、单字符无错峰)、确认时机,以及 reduced-motion 与空名的跳过路径。`apply.spec.ts` 端到端驱动跨屏暂存:创造模式草稿携带提示暂存,一次确认将其清除,重复确认让快照原样不动。`agent-preset-authoring` web e2e 在 golden 中保持空自定义分组(标题加创建入口)。 diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml new file mode 100644 index 0000000000..dcd01fc6d3 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.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-08-10-minimal-read-image-tool.md +2026-08-10-minimal-read-image-tool.md: a43e53d70e98bac7a50aa6bbabbb1e177237df01 +2026-08-10-minimal-read-image-tool.zh.md: a94e4b296425ad50876b0b45a689442c896a85a1 diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md new file mode 100644 index 0000000000..a43e53d70e --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md @@ -0,0 +1,33 @@ +# Agent Note: A minimal read_image tool over existing seams + +Status: implemented + +English | [中文](2026-08-10-minimal-read-image-tool.zh.md) + +## Problem + +The multimodal attachment work gave user uploads a complete durable path — bytes committed to the content-addressed attachment store before the owning `user/message`, an `ImageBlock` carrying only the `sha256:` reference, and the pi-ai route re-reading verified bytes per request — but the model itself had no way to look at an image on disk. `read` rejects binary content by contract, so an agent asked about a screenshot or a rendered chart either failed or shelled out to lossy workarounds. A first standalone attempt (PR #598) solved this together with loop-level route scoping: an `agent/request-ready` extension point publishing exact-model modalities before assembly, per-route schema/guidance visibility, and a reversible `image-placeholder-v1` history projection so text routes could continue over placeholder text. That design worked but coupled a tool to new agent-loop machinery, three new session-log concepts, and per-step registration churn — far more surface than the capability needs. + +## Decision + +Ship the smallest tool that loads an image into the next request's context, entirely over existing seams; the withdrawn PR #598 design is the explicit counter-example this note records. + +- **`read_image` lives in `dsh-tool-fs`** beside `read`/`write`/`edit`. Extension selects the declared PNG/JPEG/WebP/GIF media type; the attachment store's magic-byte and pixel validation stays authoritative. Bytes travel `ctx.fs.stat` → bounded `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed`, and the tool result is the metadata envelope plus a real `ImageBlock` — `ToolResultBlock.content` already admits image blocks, the pi-ai adapter already renders them, and the Web host's model-switch guard already scans tool results, so nothing downstream changes. +- **`FileSystem.readBytes(target, signal, maxBytes)`** is a new required provider primitive: the byte bound lives at the seam so no backend can buffer an unbounded file, with the stat-size short-circuit and a one-byte-past-cap stream guard against post-stat growth (`FS_TOO_LARGE`). +- **Registration is composition-conditional, execution is route-gated.** The tool registers only under `ctx.inject(['attachments'], …)` — no store, no tool. At execution, before any I/O, the strict gate resolves the calling route (latest `request/header` config, falling back to agent options) through `ctx.llm.resolveModelInfo` and requires `image` in `inputModalities`; unknown capability refuses. A refusal is a plain `isError` result, so a text route's durable history never acquires an image block and the session cannot brick its own route. +- **Code Mode forwards the image out-of-band**: a nested dispatch returns the canonical value (execution-local, no image block) and defers a `user`-role context message carrying the envelope and image, so the picture still reaches the next request. +- **llm-replay models may declare `inputModalities`**, which is what lets the two keyless ACP snapshots pin both sides of the gate — the sha256-referenced success on an image-capable replay route and the verbatim refusal on a text-only one. + +## Alternatives considered + +- **PR #598's route-scoped design** (request-ready seam, per-route schema/guidance visibility, reversible history projection) — withdrawn in favor of this note's shape. What it bought: text routes could keep running after images entered history, and the tool disappeared from prompts where it cannot succeed. What it cost: agent-loop changes, three new durable concepts (`agent/request-ready`, `messageProjection`, availability notices), and registration that churned per step. The capability itself — see an image on the next request — never needed any of it. If per-route projection becomes a real requirement, that PR's history is the reference implementation. +- **`agent.inject()` instead of the image-bearing tool result** — routes the image around the tool result as a separate injected user message. Rejected: the image *is* the tool's result; splitting them adds a second logged message with no gain, and the tool-result path already works end to end. +- **Magic-byte sniffing instead of extension declaration** — sniffing duplicates detection the attachment store already owns (sharp-backed, authoritative). The extension is only a *declaration*; a mismatch fails closed with a rename remedy rather than being silently accepted, which also keeps the model's mental map (file name ↔ content) honest. +- **Registering unconditionally and failing on a missing store** — rejected; a deployment without an attachment store cannot ever satisfy the tool, so its schema would be a standing lie. The route gate, by contrast, is per-call state and correctly lives at the execution boundary. + +## Consequences + +- A text-only route refuses instead of degrading: no placeholder projection means no delegated-viewing story here — that is deliberately the next PR (subagent image readback rebuilt on the current subagent seams). +- The route gate races a concurrent model switch; the Web host's image-aware switch guard covers its surface, and other front doors own their equivalent. Recorded as a tool-fs Known Limitation. +- Repeated image results accumulate request-token cost until compaction; content addressing deduplicates bytes only. +- The tool-result card renders the durable reference, not pixels; inline preview is deferred to the UI packages. diff --git a/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md new file mode 100644 index 0000000000..a94e4b2964 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md @@ -0,0 +1,33 @@ +# Agent Note: 基于既有 seam 的最小 read_image 工具 + +Status: implemented + +[English](2026-08-10-minimal-read-image-tool.md) | 中文 + +## 问题 + +多模态附件工作为用户上传建立了完整的持久路径:字节在所属 `user/message` 之前提交到内容寻址的附件存储,`ImageBlock` 只携带 `sha256:` 引用,pi-ai 路由在每次请求时重新读取并校验字节。但模型自己没有查看磁盘图像的手段。`read` 按约定拒绝二进制内容,因此被问到截图或渲染图表的 agent 要么失败,要么退到有损的变通做法。第一次独立尝试(PR #598)把这个问题与循环级路由作用域一起解决:新增在组装前发布确切模型模态的 `agent/request-ready` 扩展点、按路由控制 schema/指导可见性,以及可逆的 `image-placeholder-v1` 历史投影让文本路由能在占位符上继续。该设计可行,但让一个工具耦合了新的 agent-loop 机制、三个新的会话日志概念和每步的注册变动,远超这项能力本身的需要。 + +## 决定 + +只交付能把图像载入下一次请求上下文的最小工具,完全建立在既有 seam 之上;撤回的 PR #598 设计是本记录明确保留的反例。 + +- **`read_image` 放在 `dsh-tool-fs`**,与 `read`/`write`/`edit` 并列。扩展名选择声明的 PNG/JPEG/WebP/GIF 媒体类型;附件存储的魔数与像素校验保持权威。字节沿 `ctx.fs.stat` → 有界 `ctx.fs.readBytes` → `ctx.attachments.saveImage` → `fs/observed` 流动,工具结果是元数据信封加真正的 `ImageBlock`——`ToolResultBlock.content` 本就允许图像块,pi-ai 适配器本就会渲染它们,Web 宿主的模型切换防护本就会扫描工具结果,下游无需任何改动。 +- **`FileSystem.readBytes(target, signal, maxBytes)`** 是新的必备提供方原语:字节上限放在 seam 上,任何后端都无法无界缓冲文件;stat 大小先短路,随后的流最多多读一个字节以防 stat 之后的增长(`FS_TOO_LARGE`)。 +- **注册随组合条件挂载,执行按路由门禁。** 工具只在 `ctx.inject(['attachments'], …)` 作用域内注册——没有存储就没有工具。执行时在任何 I/O 之前,严格门禁通过 `ctx.llm.resolveModelInfo` 解析调用路由(最新 `request/header` 配置,缺失时回退到 agent 选项),要求 `inputModalities` 包含 `image`;能力未知即拒绝。拒绝是普通的 `isError` 结果,因此文本路由的持久历史绝不会出现图像块,会话不会毁掉自己的路由。 +- **Code Mode 以带外方式转发图像**:嵌套分派返回规范值(仅限本次执行,不含图像块),并延迟提交一条携带信封和图像的 `user` 角色上下文消息,图片仍会到达下一次请求。 +- **llm-replay 模型可以声明 `inputModalities`**,这正是两个 keyless ACP 快照能钉住门禁两侧的原因:图像路由上以 sha256 引用的成功结果,和纯文本路由上逐字的拒绝。 + +## 考虑过的替代方案 + +- **PR #598 的路由作用域设计**(request-ready 扩展点、按路由的 schema/指导可见性、可逆历史投影)——被本记录的形态取代后撤回。它换来的是:图像进入历史后文本路由仍能运行,工具在注定失败的提示词里消失。它付出的是:改动 agent-loop、三个新的持久概念(`agent/request-ready`、`messageProjection`、可用性通知)和每步变动的注册。而这项能力本身——下一次请求看到图像——从不需要这些。如果按路由投影将来成为真实需求,该 PR 的历史就是参考实现。 +- **用 `agent.inject()` 代替带图像的工具结果**——把图像绕过工具结果,作为单独注入的用户消息。拒绝:图像就是工具的结果;拆开只会多一条无收益的日志消息,而工具结果路径本就端到端可用。 +- **用魔数嗅探代替扩展名声明**——嗅探重复了附件存储已拥有的检测(基于 sharp,权威)。扩展名只是声明;不匹配时按改名修复提示失败关闭,而不是被静默接受,这也让模型对文件名与内容的对应保持诚实。 +- **无条件注册、缺存储时执行报错**——拒绝;没有附件存储的部署永远无法满足该工具,其 schema 会是常态谎言。相反,路由门禁是逐调用状态,正确的位置就是执行边界。 + +## 后果 + +- 纯文本路由得到拒绝而不是降级:没有占位符投影意味着这里没有委托查看的方案——那有意留给下一个 PR(基于当前 subagent seam 重建的 subagent image readback)。 +- 路由门禁与并发模型切换存在竞态;Web 宿主的图像感知切换防护覆盖其表面,其他前端拥有各自的等价防护。已记入 tool-fs 的已知限制。 +- 重复的图像结果在压缩之前持续累积请求 token 成本;内容寻址只去重字节。 +- 工具结果卡片渲染持久引用而非像素;内嵌预览延后到 UI 包处理。 diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.i18n.yaml new file mode 100644 index 0000000000..322d645a70 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.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-08-10-subagent-approval-pinned-never.md +2026-08-10-subagent-approval-pinned-never.md: a21c6b966b1ad00ed63e0fe87b0ce982f0daf490 +2026-08-10-subagent-approval-pinned-never.zh.md: db44ae134d34904a53691cfe78eaa5a899cf64e0 diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md new file mode 100644 index 0000000000..a21c6b966b --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.md @@ -0,0 +1,34 @@ +# Agent Note: Delegated subagents run with approvals pinned to `'never'` + +Status: implemented + +English | [中文](2026-08-10-subagent-approval-pinned-never.zh.md) + +## Problem + +A delegated child that asked for approval had no one to ask. Under an interactive parent (`'ask'`), a background child's escalation became a pending question no product surface showed — subagent sessions are omitted from the Web sidebar, the parent's `list_agents` reports plain `running`/`idle`, and the catalog rows show only activity — so a permission-blocked child was indistinguishable from a working one; headless and unanswered compositions failed the same ask closed as `'unavailable'`. The rejection audit landed only in the child's own log, and no tool parameter or Web control can adjust a running child session's sandbox mode or approval policy (Issue #1723). The mechanism-heavy fix — a durable blocked-state projection, parent notices, catalog badges, and a permission write path through the subagent ownership fence — was disproportionate directly before release. + +## Decision + +A delegated child acts only within the permission scope fixed at delegation, and approval prompts are removed from its world entirely: `captureDelegatedPolicyOverrides(parent)` (`dsh-subagent/src/child-agent.ts`) still snapshots the parent session's explicit sandbox override, but pins `approvalPolicy: 'never'` whenever the approval capability is composed — it no longer reads the parent's own approval policy. `appendDelegatedPolicyOverrides()` writes the pin as the durable `approval/policy { policy: 'never', source: 'delegation' }` event on the child's log, through the same one-shot and continuable delegation paths as the sandbox snapshot, so cold resume replays it and a fork seed's stale parent policy loses to it. + +Enforcement is the existing `ApprovalService` `'never'` semantics at the one operation that decides asks: every child ask — a `sandbox_permissions` escalation from bash or fs, a hook-driven permission question, any future asker — resolves `'rejected'` deterministically before any answerer is consulted, still leaving the `approval/asked`/`approval/decided` audit pair on the child log. The child's whole permission story is therefore its sandbox scope: a `danger-full-access` parent delegates children that need no approvals, a `read-only` parent delegates children with no escape hatch, and a widening decision always belongs to the parent side (widen the parent session, then delegate or follow up again). + +Every in-process child is told, not trapped: `applyChildComposition` registers the scoped `subagent:delegation` runtime-context statement (order 120, after the `sandbox:policy` and `approval:policy` sentences) stating that the scope was fixed at start, approval-requiring operations are rejected automatically, and a task needing wider access ends with a reported limitation instead of retries. The statement is a runtime-context contribution rather than a system-prompt section, so the deployment's system prompt stays uniform across parents and children (the snapshot suite pins that uniformity) and the fact rides the same durable snapshot as the policy sentences. + +This supersedes the approval half of the [in-process delegation-policy decision](2026-07-25-subagent-policy-inheritance.md) and reverses its "forcing `'never'` forecloses a future child answerer" verdict: approval inheritance shipped, produced the invisible blocked states above, and a future child answerer now requires reversing this note first. + +## Alternatives considered + +- **Inheriting the parent's approval override** (the prior behavior) — rejected: only a parent already at `'never'` produced deterministic children; an interactive parent seeded children whose asks waited on a prompt no one was watching or failed closed `'unavailable'`, and the outcome depended on which surfaces happened to be attached. +- **Blocked-state visibility and per-child permission adjustment** (the original #1723 acceptance) — deferred, not rejected: a `list_agents` blocked annotation, parent notices over the settlement-delivery seam, catalog badges, and a subagent-routed permission channel remain the richer design, but each needs its own seam work and none is required once children cannot enter a blocked-waiting state. +- **Routing child asks to the parent controller** — still deferred in the [approval-seam Agent Note](2026-07-06-approval-seam.md): it needs parent-chain ownership and the spawning `callId`. +- **Pinning inside `ApprovalService` by session origin** — rejected: it couples the approval package to delegation vocabulary and duplicates a decision the delegation boundary already owns; the delegation-seeded event is enforceable because no current write path can switch a child session's policy (the `/permission` command requires generic Host routing, which the subagent ownership fence denies to child sessions). + +## Consequences + +- The child's sandbox inheritance is the complete delegation permission model; the `DelegatedPolicyOverrides.approvalPolicy` field narrows to `'never' | undefined` (`undefined` only without a composed approval capability). +- Model-visible: each child's runtime-context snapshot carries the `subagent:delegation` statement plus the standing disabled-approvals sentence; parent requests are unchanged. The executor-boundary test proves a child escalation is rejected without consulting a root answerer that would have granted it, with the audit pair logged. +- Boundaries: in-process one-shot, continuable, and workflow-spawned children are enforced through the shared helpers; `subagent-acp` children keep that provider's explicit machine `permission` policy; `claude-code`, `codex`, and `dsh-sdk` children run in external processes under their own composition. +- Children persisted before the pin fold to the deployment approval default on cold resume; pre-release, no migration is added. +- Snapshot fixtures record the pin: every in-process child log gains the delegation `approval/policy` event, and `subagent-published-run-failure` now persists a one-event child log where the child previously left no durable events. diff --git a/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md new file mode 100644 index 0000000000..db44ae134d --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-subagent-approval-pinned-never.zh.md @@ -0,0 +1,34 @@ +# Agent Note: 被委派的 subagent 以钉定为 `'never'` 的审批策略运行 + +Status: implemented + +[English](2026-08-10-subagent-approval-pinned-never.md) | 中文 + +## 问题 + +被委派的子 agent 发起审批请求时无人可问。在交互式父级(`'ask'`)之下,后台子 agent 的升级请求会变成一个任何产品界面都不展示的挂起问题——subagent 会话不进入 Web 侧边栏,父级的 `list_agents` 只报告普通的 `running`/`idle`,目录树的行也只显示活动状态——因此被权限拦住的子 agent 与正常干活的子 agent 无法区分;headless 与无应答者的组合则让同一次 ask 以 `'unavailable'` 失败关闭。拒绝的审计记录只落在子 agent 自己的日志里,而且没有任何工具参数或 Web 控件能调整一个正在运行的子会话的沙箱模式或审批策略(Issue #1723)。机制繁重的修复方案——持久化的受阻状态投影、父级通知、目录树徽标,以及穿过 subagent 所有权围栏的权限写入路径——在临近发布时代价不成比例。 + +## 决策 + +被委派的子 agent 只在委派时固定的权限范围内行动,审批提示则从它的世界中彻底移除:`captureDelegatedPolicyOverrides(parent)`(`dsh-subagent/src/child-agent.ts`)仍对父会话的显式沙箱覆盖项建立快照,但只要审批能力已组合,就把 `approvalPolicy: 'never'` 钉定下来——不再读取父级自身的审批策略。`appendDelegatedPolicyOverrides()` 把这个钉定作为持久化的 `approval/policy { policy: 'never', source: 'delegation' }` 事件写入子 agent 的日志,与沙箱快照走完全相同的一次性与可继续委派路径,因此冷恢复会重放它,fork 种子中陈旧的父级策略也会输给它。 + +强制执行沿用既有的 `ApprovalService` `'never'` 语义,落在裁决 ask 的唯一操作上:子 agent 的每次 ask——bash 或 fs 的 `sandbox_permissions` 升级、hook 驱动的权限询问、任何未来的请求方——都在咨询任何应答者之前确定性地解析为 `'rejected'`,同时仍在子日志上留下 `approval/asked`/`approval/decided` 审计对。子 agent 的全部权限故事因此就是它的沙箱范围:`danger-full-access` 父级委派出的子 agent 无需任何审批,`read-only` 父级委派出的子 agent 没有任何逃生通道,而放宽的决定始终属于父级一侧(先放宽父会话,再重新委派或继续 follow-up)。 + +每个进程内子 agent 都被告知而非被困住:`applyChildComposition` 注册作用域内的 `subagent:delegation` 运行时上下文声明(order 120,位于 `sandbox:policy` 与 `approval:policy` 语句之后),声明权限范围已在启动时固定、需要审批的操作会被自动拒绝、需要更宽访问的任务应以上报限制收尾而不是重试。该声明是运行时上下文贡献而非系统提示词 section,因此部署的系统提示词在父子之间保持统一(快照测试套件钉住了这一统一性),该事实也随策略语句乘坐同一份持久化快照。 + +本决策取代[进程内委派策略决策](2026-07-25-subagent-policy-inheritance.md)中的审批一半,并推翻其「强制 `'never'` 会排除未来的子 agent 应答器」的结论:审批继承已经落地,产生的正是上述不可见的受阻状态;未来若要引入子 agent 应答器,必须先推翻本 note。 + +## 考虑过的替代方案 + +- **继承父级的审批覆盖项**(先前的行为):不予采纳。只有已处于 `'never'` 的父级才产生确定性的子 agent;交互式父级种出的子 agent,其 ask 要么等待一个无人在看的提示,要么以 `'unavailable'` 失败关闭,结果取决于当时恰好接入了哪些界面。 +- **受阻状态可见性与逐子级权限调整**(#1723 原有的验收):延后而非否决。`list_agents` 的受阻标注、经由结算投递 seam 的父级通知、目录树徽标,以及 subagent 专用的权限通道仍是更完整的设计,但每一项都需要独立的 seam 工作;一旦子 agent 不可能进入等待审批的受阻状态,这些都不再是必需。 +- **把子 agent 的 ask 路由到父控制器**:仍按[审批 seam Agent Note](2026-07-06-approval-seam.md) 延后。它需要父链所有权与发起 spawn 的 `callId`。 +- **在 `ApprovalService` 内按会话来源钉定**:不予采纳。这会让审批包耦合委派词汇,并重复一个委派边界已经拥有的决定;委派种入的事件之所以可强制执行,是因为当前不存在任何能切换子会话策略的写入路径(`/permission` 命令要求通用 Host 路由,而 subagent 所有权围栏对子会话拒绝该路由)。 + +## 后果 + +- 子 agent 的沙箱继承就是委派权限模型的全部;`DelegatedPolicyOverrides.approvalPolicy` 字段收窄为 `'never' | undefined`(仅在未组合审批能力时为 `undefined`)。 +- 模型可见:每个子 agent 的运行时上下文快照携带 `subagent:delegation` 声明以及固定的审批已禁用语句;父级请求不变。executor 边界测试证明:即使根部有一个本会批准的应答者,子 agent 的升级仍被拒绝且不咨询该应答者,审计对照常落日志。 +- 边界:进程内一次性、可继续以及 workflow 派生的子 agent 都经由共享辅助函数强制执行;`subagent-acp` 子 agent 保留该提供方显式的机器 `permission` 策略;`claude-code`、`codex` 与 `dsh-sdk` 子 agent 运行在外部进程中,由各自的组合决定。 +- 在钉定之前持久化的子 agent 冷恢复时折叠到部署审批默认值;处于预发布阶段,不添加迁移。 +- 快照夹具记录了该钉定:每个进程内子日志都新增委派 `approval/policy` 事件,`subagent-published-run-failure` 现在会持久化一份单事件子日志,而此前该子 agent 不留任何持久化事件。 diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.i18n.yaml new file mode 100644 index 0000000000..0c8a3f2781 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.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-08-10-web-session-log-export.md +2026-08-10-web-session-log-export.md: 427b6478ac44fb28030aa932630f276de7bb2edc +2026-08-10-web-session-log-export.zh.md: 63b9804a54cda7eea4ff793d78a925fe296d06cb diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md new file mode 100644 index 0000000000..427b6478ac --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.md @@ -0,0 +1,30 @@ +# Agent Note: Web session-log export as a host-streamed ZIP download + +Status: implemented + +English | [中文](2026-08-10-web-session-log-export.zh.md) + +## Problem + +The Trajectory view had no way to hand a debugging artifact to a human: the raw session log lived on disk and in the host, the client history face served folded projections (not raw entries), and a session with subagents spans many independent session logs. A bug report needs the complete raw log of the whole tree, in a shape that survives being emailed around. + +## Decision + +- **The export is a host-only download, not an RPC**: `GET /api/session.export?sessionId=…&includeDescendants=true` streams one ZIP attachment. Every file is a session's **stored artifact text verbatim**: `readRaw` on the persistence service reads the backend's own durable bytes (the JSONL backend decodes its physical zstd frames, or returns plaintext) — never a reconstruction from parsed events, so packed-chunk rows, key order, and line breaks survive byte-for-byte — under its original base name (`session.jsonl` at the root, `subagents//session.jsonl` for descendants). Compression runs on the host with fflate's streaming `Zip`/`ZipDeflate` API, each entry deflated in bounded chunks as it is produced, so the response is chunked as it is generated and the host never holds the whole archive in one buffer (at most one descendant's artifact text beyond the preloaded root), and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). No manifest is written — every file is byte-identical to the durable artifact and self-describing through its own header line. +- **Error vocabulary is HTTP-native**: missing services → 500, missing root session → 404 (both decided before any byte streams), a descendant without a stored artifact → the stream errors (fail-loud, never silent under-export). The carrier (`toFetchHandler`) already applies the `/api` trust fence; the GET branch sits beside the existing SSE GET routes, and `ApiProxy.downloads.sessionLog` (host-only, no wire envelope, absent from `IApiClient`) implements it. +- **The UI just downloads**: the 导出 button fetches the endpoint and saves the response; the `session.log` RPC that an earlier iteration shipped was removed — the download endpoint is its only consumer, and the repo rule is no public interface without a current owner. The client bundle no longer carries fflate (the earlier browser-entry-alias pitfall is moot). +- The 导出 button lives in the Trajectory toolbar; the plugin exposes `exportLog` through the view's inject face (components never touch ctx) and resolves the view tab label through the locale service (`轨迹` in Chinese, `Trajectory` in English). In-flight state disables the button; a failure surfaces in a visible alert bar under the toolbar. + +## Alternatives considered + +- **`session.log` data RPC + client-side zip** — shipped first, rejected with the user: the browser pulls the full raw JSON (≈10× the final zip size) and compresses on the main thread; for the 23 MB sessions in real use the host-side stream is strictly better. The RPC was deleted with the migration rather than left as a dead public surface. +- **Single JSONL with envelope lines for multiple sessions** — rejected with the user: mixing sessions in one JSONL loses clean per-file boundaries; a ZIP keeps one canonical file per session. +- **jszip** — heavier (~100 kB) and its dependency graph pulls readable-stream browser mappings; fflate is purpose-built and small. +- **Vendoring fflate's browser entry** — the repo vendoring procedure targets cordis-scale pinned sources; a resolveId alias keeps the maintained dependency without shipping a copy (and host-side fflate needs no alias at all). + +## Consequences + +- Export fidelity: every exported file is byte-identical to the backend's durable artifact as of the read moment (a live session may append after the read; the export reflects the durable state at read time). The archive name is `dsh-session-.zip` and archive paths sanitize ids before they can shape entries. +- `readRaw` joins the persistence service as a concrete default (`undefined` for backends without a per-session artifact, e.g. SQLite) with a JSONL-backend override that owns the compression decode. `ApiProxy.downloads.sessionLog` adds one host-only member to the contract plus a host-side query schema and a GET branch in the fetch handler — no RPC map row, envelope schema, or client `IApiClient` surface. +- Fixture mode (no host) answers 404 for the export, so the button's error bar explains the gap instead of hanging; the navigation-panes golden snapshot includes the 导出 button. +- Deferred: transcript.md and a report/feedback bundle remain future work; the byte-faithful, manifest-free shape keeps the v2 bundle extension cheap. diff --git a/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md new file mode 100644 index 0000000000..63b9804a54 --- /dev/null +++ b/.agents/notes/implemented/feature/2026-08-10-web-session-log-export.zh.md @@ -0,0 +1,30 @@ +# Agent Note:Web 会话日志导出——宿主流式 ZIP 下载 + +状态:implemented + +[English](2026-08-10-web-session-log-export.md) | 中文 + +## 问题 + +Trajectory 视图没有任何方式把调试工件交到人手里:原始会话日志存放在磁盘与宿主侧,客户端历史面只提供折叠后的投影(而非原始事件),而带子代理的会话横跨多个相互独立的会话日志。bug 报告需要整棵会话树的完整原始日志,并且形态要能在被转发后仍然可用。 + +## 决策 + +- **导出是宿主侧的下载面,不是 RPC**:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP 附件。每个文件都是会话**存储工件的逐字原文**:持久化服务新增的 `readRaw` 读取后端自己的持久化字节(jsonl 后端解码其物理 zstd 帧,或直接返回明文)——绝非从解析后事件重建,因此 chunk 打包、键序、换行全部逐字节保留——放在其原始基础文件名下(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`)。压缩在宿主侧用 fflate 的流式 `Zip`/`ZipDeflate` API 完成,每个条目按有界分块边产出边压缩,响应随生成分块写出,宿主从不把整个归档放进单个缓冲区(除预载的根外,最多同时持有一条后代的工件文本),且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。不写清单——每个文件都与持久化工件逐字节一致,并通过自身 header 行自描述。 +- **错误词汇是 HTTP 原生的**:服务缺失 → 500,根会话缺失 → 404(两者都在任何字节流出前判定),后代缺少存储工件 → 流失败(fail-loud,绝不静默少导出)。载体(`toFetchHandler`)已对 `/api` 应用信任围栏;GET 分支与既有 SSE GET 路由并列,由 `ApiProxy.downloads.sessionLog`(host-only、无 wire 信封、不在 `IApiClient` 上)实现。 +- **UI 只负责下载**:「导出」按钮 fetch 该端点并保存响应;早先迭代发布的 `session.log` RPC 已删除——下载端点是它唯一的消费者,仓库规则是不留无当前所有者的公共接口。客户端 bundle 不再携带 fflate(早先的浏览器入口别名坑随之消失)。 +- 「导出」按钮位于 Trajectory 工具栏;插件通过视图的 inject face 暴露 `exportLog`(组件从不接触 ctx),并通过 locale 服务解析视图标签页标题(中文「轨迹」、英文 "Trajectory")。进行中状态会禁用按钮;失败会在工具栏下方的可见警示条中显示。 + +## 考虑过的替代方案 + +- **`session.log` 数据 RPC + 客户端打包**——先发布,后与用户共同否决:浏览器要拉取完整原始 JSON(约为最终 zip 的 10 倍)并在主线程压缩;对实际使用中 23 MB 级别的会话,宿主流式严格更优。迁移时把该 RPC 一并删除,而不是留作无消费者的公共接口。 +- **用信封行把多会话编码进单一 JSONL**——与用户共同否决:把多个会话混进一个 JSONL 会失去干净的按文件边界;ZIP 让每个会话保持一个规范文件。 +- **jszip**——更重(约 100 kB),依赖图还会拉入 readable-stream 的浏览器映射;fflate 专为此而生且体积小。 +- **将 fflate 浏览器入口 vendoring 进仓库**——仓库的 vendoring 流程面向 cordis 级别的固定源码;resolveId 别名在保持维护中的依赖的同时无需复制代码(宿主侧 fflate 根本不需要别名)。 + +## 后果 + +- 导出保真度:每个导出文件都与读取时刻的后端持久化工件逐字节一致(活跃会话可能在读取后继续追加;导出反映的是读取时的持久化状态)。压缩包名为 `dsh-session-.zip`,归档路径在塑造条目前会先净化会话 id。 +- `readRaw` 以具体默认(无每会话工件的后端如 SQLite 返回 `undefined`)加入持久化服务,jsonl 后端覆写并自持压缩解码。`ApiProxy.downloads.sessionLog` 为契约新增一个 host-only 成员,外加宿主侧 query schema,并在 fetch handler 加一个 GET 分支——没有 RPC map 行、信封 schema 或客户端 `IApiClient` 面。 +- fixture 模式(无宿主)对导出应答 404,按钮的错误条会解释这个缺口而非挂起;navigation-panes golden 快照包含「导出」按钮。 +- 暂缓:transcript.md 以及 report/feedback 打包留待后续;逐字节忠实、无清单的形态让 v2 的打包扩展保持廉价。 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml index 36f6181c46..56f184e831 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.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/process/2026-07-13-documentation-site-projection.md -2026-07-13-documentation-site-projection.md: d9af915754fa6a1df51a27d18d412597472aaa73 -2026-07-13-documentation-site-projection.zh.md: 7d7b4752b8f27d55aae8426a7dc001ce4340e661 +2026-07-13-documentation-site-projection.md: 309dbd96c5f1ca87d137cdc3839acfc5c5aa22f2 +2026-07-13-documentation-site-projection.zh.md: b01965fb1b46ab618b5b8bcb748bb5a8940d6e69 diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md index d9af915754..309dbd96c5 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.md @@ -18,9 +18,9 @@ Canonical Markdown remains in the repository tier that owns it. Product-facing g Locale home projections retain only the canonical YAML frontmatter. The repository-facing body can keep its H1 and bilingual source links, while the VitePress home theme owns the rendered hero and features and the site navigation owns locale switching. -The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a source link under the public `deepseek-ai/deepseek-harness-sdk` home; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. +The projector parses Markdown links without reserializing the document. A link to another published source becomes a site-relative route; a link to an unpublished repository file becomes a source link under the `deepseek-ai/deepseek-harness` repository home; a repository image is copied into the generated tree and referenced from there ([why](2026-08-06-doc-site-carries-its-images.md)). Missing relative targets fail projection. Unit tests pin these transformations, and `docs:check` runs the projector tests plus a production VitePress build as part of `doc-sync` and the parallel documentation gates. -`verify-public-repository-links` rejects internal repository remotes from tracked files. Public source links use the public home, while work tracking stays in repository metadata and source carries a TODO only when the local boundary matters to maintainers. +`verify-public-repository-links` rejects references to the unavailable legacy repository from tracked files. Source and edit links use the current repository home. `website/AGENTS.md` is the only maintained Markdown file in the website subtree. The projector test enumerates tracked and unignored files and rejects any other website Markdown, so site-specific locale, route, API, or generated source copies cannot bypass the publication manifest. diff --git a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md index 7d7b4752b8..b01965fb1b 100644 --- a/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md +++ b/.agents/notes/implemented/process/2026-07-13-documentation-site-projection.zh.md @@ -18,9 +18,9 @@ Status: implemented 各 locale 的首页投影只保留权威 YAML frontmatter。面向仓库的正文可以保留其 H1 和双语源文件链接,而 VitePress 首页主题负责渲染 hero 与功能区,网站导航负责切换 locale。 -投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成公开 `deepseek-ai/deepseek-harness-sdk` 主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 +投影器解析 Markdown 链接,但不会重新序列化文档。指向另一个已发布源文件的链接会变成站内相对路由;指向未发布仓库文件的链接会变成 `deepseek-ai/deepseek-harness` 仓库主页下的源文件链接;仓库图片会被拷贝进生成树并从那里引用([原因](2026-08-06-doc-site-carries-its-images.md))。相对目标不存在时,投影会失败。单元测试会锁定这些转换行为,`docs:check` 则运行投影器测试和 VitePress 生产构建,并将二者纳入 `doc-sync` 和并行文档门禁。 -`verify-public-repository-links` 会拒绝已跟踪文件中的内部仓库远程链接。公开源文件链接使用公开主页,而工作跟踪留在仓库元数据中;只有本地边界对维护者有意义时,源文件才保留 TODO。 +`verify-public-repository-links` 会拒绝已跟踪文件中指向不可用旧仓库的引用。源文件链接和编辑链接使用当前仓库主页。 `website/AGENTS.md` 是网站子树中唯一维护的 Markdown 文件。投影器测试会枚举所有已跟踪文件和未被忽略的未跟踪文件,并拒绝网站中的任何其他 Markdown,因此网站专用的 locale、路由、API 或生成源文件副本无法绕过发布 manifest。 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml index 33bffe92ff..aa3cabead0 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.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/process/2026-07-21-serial-cross-platform-ci-reference.md -2026-07-21-serial-cross-platform-ci-reference.md: 07dc430e6fed3fe75a006ca03523bd6e4fc969d0 -2026-07-21-serial-cross-platform-ci-reference.zh.md: 8bbb60cdead2957069de22ecaddf01c6cd9fb305 +2026-07-21-serial-cross-platform-ci-reference.md: c2ed11d40f7f5487117b5c72f11bc1709042f68a +2026-07-21-serial-cross-platform-ci-reference.zh.md: bef8c3b640cf43942e380e921d5f62d91723eff8 diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md index 07dc430e6f..c2ed11d40f 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.md @@ -16,7 +16,7 @@ Real-kernel sandbox proofs require specific hosted operating systems and archite ## Decision -[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active reference is `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool — the hot-standby drill that continuously re-proves the failover target described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux`, `serial / macos`, and `serial / windows` definitions remain disabled under `TODO(hosted-serial-ci)` until their portable capacity can be restored. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. +[CI](../../../../.github/workflows/ci.yml) gives pull-request and master-push events complementary responsibilities. Pull requests run consolidated Linux and Wine-hosted Windows jobs plus the Node compatibility and Python contracts on standard GitHub-hosted capacity; an independent native Windows job reports the complete Windows inventory without participating in the required aggregate. On a push to `master`, the active references are `serial / linux (self-hosted standby)` on the in-house `vm-backup` pool and `serial / windows (self-hosted standby)` on the in-house `dsh-win-ci` pool — the hot-standby drills that continuously re-prove the failover targets described in the [failover runbook](2026-07-26-ci-failover-runbook.md). The standard-hosted `serial / linux`, `serial / macos`, and `serial / windows` definitions remain disabled under `TODO(hosted-serial-ci)` until their portable capacity can be restored. The separate job definitions intentionally keep their short checkout, runtime setup, and immutable install sequences visible instead of hiding operating systems behind a matrix or reusable workflow. `workflow_dispatch` is reserved for runner benchmarks. Each reference job runs `pnpm run check:ci` without any shard selector. `DSH_GATE_CONCURRENCY=1` makes the top-level aggregate execute one ready gate at a time; coverage, snapshot replay, built-bin smoke, and publication validation also receive worker counts of one. The reference jobs may run beside one another, but each host's repository gates are serial and complete. Linux installs bubblewrap before replaying snapshots, and Windows enables Developer Mode before installing the symlinked workspace. @@ -28,7 +28,7 @@ The standalone [Sandbox](../../../../.github/workflows/sandbox.yml) workflow bel Master reference jobs are diagnostic and do not participate in the pull request's required `all checks passed` result. The CI and Sandbox workflows keep their cross-platform references on master pushes. Performance is evaluated from completed hosted-job timestamps and reported as a measurement; it is not encoded as a `timeout-minutes` value. -The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses standard `windows-2025` under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. +The portable reference uses GitHub's standard `ubuntu-latest`, `macos-latest`, and `windows-2025` labels. The required pull-request Windows job runs under Wine on `ubuntu-latest`, while the independent pull-request native job uses the hosted `dsh-windows-2025-16core` runner under normal operation and the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under failover (see the [failover runbook](2026-07-26-ci-failover-runbook.md)), and is absent from the required aggregate under the [dual Windows decision](2026-08-08-native-windows-pull-request-ci.md); when enabled, `serial / windows` remains a second complete, unsharded native-kernel oracle. Required pull-request jobs use portable standard capacity under the [required-CI decision](2026-07-23-portable-required-pull-request-ci.md). Higher-core hosted runners remain manual benchmarks because a correctness path must remain runnable without repository-external runner configuration. ## Alternatives considered diff --git a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md index 8bbb60cdea..bef8c3b640 100644 --- a/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md +++ b/.agents/notes/implemented/process/2026-07-21-serial-cross-platform-ci-reference.zh.md @@ -16,7 +16,7 @@ Status: implemented ## 决策 -[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)`——该热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux`、`serial / macos` 和 `serial / windows` 定义仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 +[CI](../../../../.github/workflows/ci.yml) 为拉取请求事件与 master 推送事件赋予互补的职责。拉取请求在 GitHub 标准托管容量上运行合并后的 Linux 和由 Wine 承载的 Windows 作业,以及 Node 兼容性与 Python 约定;一个独立的原生 Windows 作业会报告完整的 Windows 清单,但不参与必需聚合流程。向 `master` 推送时,当前启用的参考作业是公司自有 `vm-backup` 池上的 `serial / linux (self-hosted standby)` 和 `dsh-win-ci` 池上的 `serial / windows (self-hosted standby)`——这些热备演练持续验证[故障切换手册](2026-07-26-ci-failover-runbook.md)所描述的切换目标。标准托管的 `serial / linux`、`serial / macos` 和 `serial / windows` 定义仍处于禁用状态,并由 `TODO(hosted-serial-ci)` 标记,直到其可移植容量恢复。各自独立的作业定义有意显式保留简短的代码检出、运行时设置和依赖锁定的安装步骤,而不是用矩阵或可复用工作流隐藏操作系统差异。`workflow_dispatch` 仅用于运行器基准测试。 每个参考作业均在不设置任何分片选择器的情况下运行 `pnpm run check:ci`。`DSH_GATE_CONCURRENCY=1` 使顶层聚合每次只执行一个已经就绪的门禁;覆盖率、快照回放、built-bin 冒烟测试和发布验证的 worker 数量也设为 1。各参考作业可以彼此并行,但每台主机上的仓库门禁都串行运行且完整执行。Linux 在回放快照前安装 bubblewrap,Windows 则在安装采用符号链接的工作区前启用开发人员模式。 @@ -28,7 +28,7 @@ macOS 参考流程使用 fork 进程运行常规 Vitest 项目。macOS arm64 上 master 分支的参考作业仅用于诊断,不参与拉取请求所要求的 `all checks passed` 结果。CI 与 Sandbox 工作流把跨平台参考流程保留在 master 推送上。系统根据已完成托管作业的时间戳评估性能,并将其报告为测量结果,而不是写成 `timeout-minutes` 值。 -可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)使用标准 `windows-2025`;`serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 +可移植的参考流程使用 GitHub 标准的 `ubuntu-latest`、`macos-latest` 和 `windows-2025` 标签。拉取请求必需的 Windows 作业在 `ubuntu-latest` 上通过 Wine 运行,而独立的拉取请求原生作业在正常运行下使用托管的 `dsh-windows-2025-16core` 运行器,故障切换时使用自托管 `[self-hosted, dsh-win-ci, windows]` 池(参见[故障切换手册](2026-07-26-ci-failover-runbook.md)),依据[双 Windows 决策](2026-08-08-native-windows-pull-request-ci.md)不参与必需聚合流程;`serial / windows` 启用时,仍作为第二个完整且未分片的原生内核标尺。依据[必需 CI 决策](2026-07-23-portable-required-pull-request-ci.md),拉取请求必需作业使用可移植的标准容量。更高核心数的托管运行器仍仅用于手动基准测试,因为正确性路径必须无需仓库外部的运行器配置即可运行。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml index f4fe0c74d2..8615d248fc 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.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/process/2026-07-26-ci-failover-runbook.md -2026-07-26-ci-failover-runbook.md: 72261f95ea74b61e3915a1a6419b2c2e616efbd9 -2026-07-26-ci-failover-runbook.zh.md: 1ee679eabc296ab31d71b87945409788539425bd +2026-07-26-ci-failover-runbook.md: 47901844f4ec581dff8500cc429c54a076b7642b +2026-07-26-ci-failover-runbook.zh.md: 88a793e144a4d06718ffe323a94f8e81f8e62b82 diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md index 72261f95ea..47901844f4 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md @@ -6,25 +6,29 @@ English | [中文](2026-07-26-ci-failover-runbook.zh.md) ## Problem -The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: this switch recovers an enterprise Linux-pool outage.** The verdict's other required dependencies (`node-compat`, `python-sdk`, `windows`) stay on standard hosted runners by design (the portable boundary); in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block `all checks passed`, and only the Windows leg has no in-house substitute at all — during the 2026-07-27 outage the standard pools recovered first, which is the ordering this design bets on. An outage therefore needs a switch any responder with repository write access can throw without merging anything. +The three required Linux worker jobs in [CI](../../../../.github/workflows/ci.yml) (`node 24 / static`, `node 24 / coverage`, `node 24 / snapshots and artifacts`) run on the hosted enterprise 32-core pools; the required verdict job that aggregates them (`all checks passed`) runs on standard `ubuntu-latest`; the independent native Windows job (`windows node 24 / native complete`) runs on the hosted `dsh-windows-2025-16core` larger runner. When the enterprise pools degrade — jobs queue indefinitely or the enterprise labels vanish — every open pull request becomes unmergeable, and the ordinary recovery of merging a fix is itself deadlocked behind the very required checks that cannot run. **Scope: this switch recovers an enterprise Linux-pool outage AND a hosted Windows-pool outage.** The verdict's other required dependencies (`node-compat`, `python-sdk`, `windows`) stay on standard hosted runners by design (the portable boundary); in a broader GitHub-hosted capacity failure that also takes out the standard pools, those dependencies still block `all checks passed`. An outage therefore needs a switch any responder with repository write access can throw without merging anything. ## Decision -Each of the three required Linux worker jobs — and the `all checks passed` verdict job, which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all four retarget onto the in-house self-hosted `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped. The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pool's readiness is continuously re-proven by the `serial / linux (self-hosted standby)` lane, which runs the complete unsharded aggregate on every master push. +Each of the three required Linux worker jobs, the independent native Windows job, and the `all checks passed` verdict job — which would otherwise stay queued on the failed pool even after every worker passed — resolves its runner pool through the `DSH_CI_FAILOVER` repository variable. Unset (normal), they run on the hosted enterprise pools. Set to `selfhosted` by any repository writer, all five retarget onto the in-house self-hosted pools: the Linux jobs and verdict onto the `vm-backup` pool, coverage and snapshot concurrency drop to shared-VM bounds, and the hosted-path pnpm cache restores are skipped; the native Windows job onto the `dsh-win-ci` pool. The switch is writer-manageable repository state, not a merge, so it works while every check is red. The in-house pools' readiness is continuously re-proven by the `serial / linux (self-hosted standby)` and `serial / windows (self-hosted standby)` lanes, which run the complete unsharded aggregates on every master push. ### What the in-house pool is `vm-backup`: one 64-core VM, six always-on systemd-managed runner instances. Its image must preinstall Playwright Chromium's Linux system packages; CI downloads the lockfile-selected browser but never runs `apt` on this persistent shared host. Check the latest `serial / linux (self-hosted standby)` run before switching: its aggregate includes browser replay, so a green standby verifies both ordinary capacity and this browser prerequisite. +#### Windows pool + +`dsh-win-ci`: 32 always-on runner instances (scheduled tasks `GH-Runner-01`…`GH-Runner-32`) on the in-house Windows CI server (one 96-core / 580 GB machine). Labels: `[self-hosted, dsh-win-ci, windows]`. The image must preinstall Node 24, pnpm, Git (with Git Bash on `PATH`, i.e. `C:\Program Files\Git\bin` — the `bash` tool spawns `bash` by name), PowerShell 7, and enable Developer Mode for symlink support. Check the latest `serial / windows (self-hosted standby)` run before switching: a green standby verifies the pool can execute `check:ci:windows-complete` end-to-end. + ### Switch (any repository writer, ~1 minute, no merge) 1. Repository **Settings → Secrets and variables → Actions → Variables → New repository variable**: name `DSH_CI_FAILOVER`, value `selfhosted`. 2. Retrigger the required jobs so they re-resolve their pool. Jobs already **queued** for the hosted labels do not retarget and cannot be re-run in place, so for the documented indefinite-queue outage, cancel the stuck run and re-run all jobs, or push a new commit; "Re-run failed jobs" only helps once a job has actually failed rather than queued. 3. That is the entire switch. Under failover the workflow also, automatically: drops `DSH_COVERAGE_MAX_WORKERS` to 8 and `DSH_SNAPSHOT_MAX_CONCURRENCY` to 12 (sized for six always-on instances: worst case 6 × 8 = 48 coverage workers on the 64-core VM) (shared-VM contention bounds), and skips the hosted-path pnpm cache restores (the VM's persistent store serves warm installs). -#**Dependabot exception.** All four selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VM. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. +#**Dependabot exception.** All five selectors deliberately exclude `dependabot[bot]`: under failover, Dependabot PRs stay queued for the hosted pool rather than executing dependency-supplied code on the persistent VMs. A Dependabot PR that remains queued during an outage is expected behavior, not a failed switch; it completes when the hosted pool recovers. -**Who can flip the variable.** GitHub's API lets any collaborator with write access manage repository variables, so the switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner group admits all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VM by pushing a branch workflow. The boundary against untrusted code is repository membership; the variable only routes work for members. +**Who can flip the variable.** GitHub's API lets any collaborator with write access manage repository variables, so the switch is writer-level, not strictly admin-only. In this repository's trust model that is not an escalation: the runner groups admit all workflows of this private, fork-disabled repository (a deliberate trade to make PR-ref failover possible at all), so any writer could already reach the VMs by pushing a branch workflow. The boundary against untrusted code is repository membership; the variable only routes work for members. ## Capacity during failover diff --git a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md index 1ee679eabc..88a793e144 100644 --- a/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md +++ b/.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.zh.md @@ -6,23 +6,27 @@ Status: implemented ## 问题 -[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:本切换恢复的是企业级 Linux 池故障。**判定作业的其余必需依赖(`node-compat`、`python-sdk`、`windows`)按设计留在标准托管运行器上(可移植边界);若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 `all checks passed`,且只有 Windows 这条腿完全没有自有替代——2026-07-27 的故障中标准池率先恢复,本设计押注的正是这一顺序。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 +[CI](../../../../.github/workflows/ci.yml) 中三个必需的 Linux 工作作业(`node 24 / static`、`node 24 / coverage`、`node 24 / snapshots and artifacts`)运行在托管的企业级 32 核池上;聚合它们的必需判定作业(`all checks passed`)运行在标准 `ubuntu-latest` 上;独立的原生 Windows 作业(`windows node 24 / native complete`)运行在托管的 `dsh-windows-2025-16core` 大型运行器上。当企业池发生故障——作业无限排队或企业标签消失——所有开启的拉取请求都无法合并,而"合并一个修复"这一常规恢复手段本身正被那些无法运行的必需检查死锁。**适用范围:本切换恢复的是企业级 Linux 池故障与托管 Windows 池故障。**判定作业的其余必需依赖(`node-compat`、`python-sdk`、`windows`)按设计留在标准托管运行器上(可移植边界);若更大范围的 GitHub 托管容量故障连标准池一并击倒,这些依赖仍会阻塞 `all checks passed`。因此故障需要一个任何具备仓库写权限的响应者都能在不合并任何代码的情况下触发的开关。 ## 决策 -三个必需的 Linux 工作作业——以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,四者全部切换到公司自有的自托管 `vm-backup` 池,覆盖率与快照的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 +三个必需的 Linux 工作作业、独立的原生 Windows 作业,以及 `all checks passed` 判定作业(若不随切换,即使全部工作作业通过,它仍会滞留在故障池的队列中)——各自通过仓库变量 `DSH_CI_FAILOVER` 解析运行器池。变量不存在(正常)时它们运行在托管企业池上;由任何具备写权限的协作者设为 `selfhosted` 时,五个作业全部切换到公司自有的自托管池:Linux 作业与判定作业切到 `vm-backup` 池,覆盖率与快照的并发降到共享虚拟机上限,并跳过托管路径的 pnpm 缓存恢复;原生 Windows 作业切到 `dsh-win-ci` 池。这个开关是写者可管理的仓库状态而非一次合并,因此在所有检查都是红色时仍然有效。自有池的就绪状态由 `serial / linux (self-hosted standby)` 与 `serial / windows (self-hosted standby)` 通道持续验证——每次 master 推送都在其上运行完整的未分片聚合流程。 ### 自有池是什么 `vm-backup`:一台 64 核虚拟机,6 个常驻 systemd 管理的运行器实例。其镜像必须预装 Playwright Chromium 的 Linux 系统软件包;CI 会下载锁文件选定的浏览器,但绝不在这台持久化共享主机上运行 `apt`。切换前先看 `serial / linux (self-hosted standby)` 最近一次运行:其聚合流程包含浏览器回放,因此绿色热备同时验证常规容量和这项浏览器先决条件。 +#### Windows 池 + +`dsh-win-ci`:公司内部 Windows CI 服务器(一台 96 核 / 580 GB 机器)上 32 个常驻运行器实例(计划任务 `GH-Runner-01`…`GH-Runner-32`)。标签:`[self-hosted, dsh-win-ci, windows]`。镜像必须预装 Node 24、pnpm、Git(Git Bash 在 `PATH` 上,即 `C:\Program Files\Git\bin`——`bash` 工具按名称 spawn `bash`)、PowerShell 7,并为符号链接支持启用开发人员模式。切换前先看 `serial / windows (self-hosted standby)` 最近一次运行:绿色热备验证该池能端到端执行 `check:ci:windows-complete`。 + ### 切换步骤(任何具备写权限的协作者,约 1 分钟,无需合并) 1. 仓库 **Settings → Secrets and variables → Actions → Variables → New repository variable**:名称 `DSH_CI_FAILOVER`,值 `selfhosted`。 2. 重新触发必需作业,使其重新解析运行器池。已经为托管标签**排队**的作业不会重定向,也无法原地 re-run,因此对于本手册所述的无限排队故障,应取消卡住的运行并 re-run all jobs,或推送一个新提交;“Re-run failed jobs”只有在作业真正失败(而非仍在排队)时才有用。 3. 切换到此完成。故障切换状态下工作流还会自动:把 `DSH_COVERAGE_MAX_WORKERS` 降为 8、`DSH_SNAPSHOT_MAX_CONCURRENCY` 降为 12(按 6 个常驻实例定容:最坏情况下,6 × 8 = 48 个覆盖率工作进程运行在 64 核虚拟机上)(共享虚拟机的争抢上限),并跳过托管路径的 pnpm 缓存恢复(虚拟机的持久 store 直接提供热安装)。 -#**Dependabot 例外。**四个选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。 +#**Dependabot 例外。**五个选择器都刻意排除了 `dependabot[bot]`:故障切换期间,Dependabot 拉取请求继续在托管池排队,而不是把依赖项提供的代码放到持久化虚拟机上执行。故障期间 Dependabot PR 持续排队是预期行为而非切换失败;托管池恢复后它会自行完成。 **谁能扳动这个变量。**GitHub 的 API 允许任何具有写权限的协作者管理仓库变量,因此该开关实际是写者级而非严格的管理员级。在本仓库的信任模型下这并不构成升权:runner group 接纳本私有、禁 fork 仓库的全部工作流(这是让 PR 引用的故障切换得以成立的刻意取舍),因此任何写者本就可以通过推送分支工作流触达这台虚拟机。抵御不可信代码的边界是仓库成员资格;变量只是为成员路由工作。 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml index b60f3ae3ed..6f6823e3c3 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.i18n.yaml +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.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/process/2026-07-30-generated-third-party-notices.md -2026-07-30-generated-third-party-notices.md: 58e3fd5007a16d6391956f423d1ec91e851df295 -2026-07-30-generated-third-party-notices.zh.md: 834ec4ebc349bdee37b0873109de566223bb7d48 +2026-07-30-generated-third-party-notices.md: d13eef8412ecd5c8387a7239b812c835671b6bce +2026-07-30-generated-third-party-notices.zh.md: 0af6a6bcd665197acd6a0b5e9e6f51940a0267e7 diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md index 58e3fd5007..d13eef8412 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md @@ -20,9 +20,9 @@ One trigger gap is accepted rather than worked around: lefthook inspects only fi The file discloses **direct** dependencies by default. The complete npm closure with pinned versions already lives in `pnpm-lock.yaml` (`pnpm licenses list` renders it) and the Python closure in `python/sdk/uv.lock`; re-materializing either as prose would be a second, worse copy. The one explicit transitive disclosure is the official Claude platform payload set declared by `@anthropic-ai/claude-agent-sdk` through `optionalDependencies`, because those packages carry the distributed Claude Code executable rather than ordinary library implementation detail. -**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/support/`, `packages/client/test-runtime/`, `website/`, `examples/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the `bin/dsh` launcher execs through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly). +**Tiering is by declaring area, not by manifest section.** A package is a runtime dependency when any manifest outside `DEV_ONLY_AREAS` — the root manifest, `packages/support/`, `packages/client/test-runtime/`, `website/`, `examples/`, `native/` — names it under `dependencies` or `optionalDependencies`. Section names alone are wrong in both directions: a test-support package declares `vitest` under `dependencies` without shipping it, and the root source-run scripts execute through `tsx`, which no manifest declares as a runtime dependency at all (the generator marks it runtime explicitly). -The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. `scripts/install.sh` installs the repository itself, so a user's `cordis.yml` can mount any plugin package; `@modelcontextprotocol/sdk` and the OpenTelemetry packages reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice. +The runtime tier deliberately covers **every mountable plugin**, not just what the CLI, Web UI, and Python runtime load by default. Source execution can mount any plugin package from a user's `cordis.yml`; `@modelcontextprotocol/sdk` and the OpenTelemetry packages therefore reach real users even though no default assembly imports them. Under-disclosure is the costly direction for a legal notice. The manifest set is derived from the `packages:` members the root `pnpm-workspace.yaml` declares, including the Landlock workspace and its public packages, so a new member area is read the day it is declared rather than the day someone remembers to extend a list. License and repository metadata come from the root workspace's installed pnpm store and package-local link farms, so the generator requires an installed tree and fails loud when a package resolves to neither, rather than emitting an empty cell. `OVERRIDES` carries the packages whose published manifest cannot answer — Rust-built npm bins that omit `license`, and the `modelcontextprotocol/servers` packages whose repository is mid MIT→Apache-2.0 relicensing, so their effective terms are per-contribution. A runtime dependency whose license is not on the permissive list is a hard error: shipping copyleft is a distribution decision, not something a regenerated table may absorb silently. Vendored packages are cross-checked against `vendor/README.md` and rejected if any is not MIT, and `pnpm-workspace.yaml`'s `patchedDependencies` are listed under the runtime table because pnpm applies those patches at install time — shipped artifacts carry modified copies of `@earendil-works/pi-tui` and `node-pty`, and the patch files are the record of what changed. diff --git a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md index 834ec4ebc3..0af6a6bcd6 100644 --- a/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md +++ b/.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.zh.md @@ -20,9 +20,9 @@ Status: implemented 文件默认只披露**直接**依赖。完整的 npm 闭包连同锁定版本已记录在 `pnpm-lock.yaml`(`pnpm licenses list` 可渲染),Python 闭包记录在 `python/sdk/uv.lock`;再用散文誊一遍只会得到一份更差的副本。唯一明确披露的传递依赖,是 `@anthropic-ai/claude-agent-sdk` 通过 `optionalDependencies` 声明的官方 Claude 平台载荷集合,因为这些包承载随产品分发的 Claude Code 可执行文件,而非普通的库实现细节。 -**分层依据是声明方所在区域,而非 manifest 字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一 manifest——即根 manifest、`packages/support/`、`packages/client/test-runtime/`、`website/`、`examples/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而 `bin/dsh` 启动器通过 `tsx` 执行,根本没有任何 manifest 把它声明为运行时依赖,只能由生成器显式标记。 +**分层依据是声明方所在区域,而非 manifest 字段名。** 只要 `DEV_ONLY_AREAS` 之外的任一 manifest——即根 manifest、`packages/support/`、`packages/client/test-runtime/`、`website/`、`examples/`、`native/` 之外——在 `dependencies` 或 `optionalDependencies` 里点名某个包,它就是运行时依赖。单看字段名在两个方向上都会出错:测试支撑包把 `vitest` 写在 `dependencies` 里却并不交付它;而根目录的源码运行脚本通过 `tsx` 执行,根本没有任何 manifest 把它声明为运行时依赖,只能由生成器显式标记。 -运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。`scripts/install.sh` 安装的就是仓库本身,用户的 `cordis.yml` 可以挂载任何插件包;`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 +运行时层刻意覆盖**所有可挂载的插件**,而不止 CLI、Web UI 与 Python 运行时默认加载的那些。从源码运行时,用户可以通过 `cordis.yml` 挂载任何插件包;因此,`@modelcontextprotocol/sdk` 与 OpenTelemetry 系列即使没有任何默认装配引入,也会触达真实用户。对法务披露而言,披露不足才是代价更高的那个方向。 manifest 集合由根 `pnpm-workspace.yaml` 声明的 `packages:` 成员派生,其中包括 Landlock 工作区及其公开包,因此新增成员区域在声明当天就会被读取,而不必等谁想起来去补一份列表。许可证与仓库地址取自根工作区已安装的 pnpm store 和包本地链接场;某个包两处都解析不到时直接失败,而不是留下空单元格。`OVERRIDES` 收录已发布 manifest 答不上来的包:用 Rust 构建、发布时省略 `license` 字段的 npm 可执行包,以及 `modelcontextprotocol/servers` 系列——该仓库正处在 MIT 向 Apache-2.0 的重新许可过程中,实际条款按贡献逐条而定。运行时依赖的许可证若不在宽松清单内即为硬失败:交付 copyleft 是一项分发决策,不该被一次重新生成悄悄吸收。被源码收编的包会与 `vendor/README.md` 交叉核对,出现非 MIT 即报错;`pnpm-workspace.yaml` 的 `patchedDependencies` 列入运行时表格,因为 pnpm 在安装期就会打上这些补丁——交付产物携带的是改动过的 `@earendil-works/pi-tui` 与 `node-pty`,补丁文件本身就是改动的完整记录。 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml new file mode 100644 index 0000000000..08607d5317 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.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-10-event-directed-pr-review-status.md +2026-08-10-event-directed-pr-review-status.md: 9db9c64fc87c1701028ae825357c3cbd7fef44d1 +2026-08-10-event-directed-pr-review-status.zh.md: 381a3f64a62930a584f48cfbc3571679bbcbcef7 diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md new file mode 100644 index 0000000000..9db9c64fc8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md @@ -0,0 +1,41 @@ +# Agent Note: Event-directed PR review status commands + +Status: implemented + +English | [中文](2026-08-10-event-directed-pr-review-status.zh.md) + +## Problem + +The Issue Project status records who owns the next step of resolving work. Aggregate pull-request review state answers whether GitHub considers the pull request mergeable, but it cannot represent that handoff: an earlier `CHANGES_REQUESTED` review can remain effective after the author fixes the code and requests review again. + +A monotonic projection also cannot return an automation-owned Issue from `In review` to `In progress` when a reviewer requests changes. Reconstructing review rounds or reviewer blockers would add state that the required two-event contract does not need. + +## Decision + +The Issue lifecycle workflow treats review webhooks as commands. `pull_request.review_requested`, including a repeated request, targets `In review`. `pull_request_review.submitted` targets `In progress` only when `review.state` is `changes_requested`; the submitted event remains necessary because a reviewer can request changes without an earlier review-request event. Approved and commented submissions skip their lifecycle job before it creates a Project token, while dismissed reviews are not subscribed. + +Ordinary subscribed pull-request events remain forward-only implementation signals: they can move `Inbox`, `Backlog`, or `Ready` to `In progress`, but they cannot move `In review` backward. Review-request commands can move any earlier active status to `In review`. Changes-requested commands can move earlier active statuses forward to `In progress` and can move `In review` back only when the latest status event for the target Project was written by the configured lifecycle actor. A human or unknown latest actor preserves the current status. + +The handler resolves only exact same-repository `Fixes`, `Closes`, or `Resolves` references. It does not alter terminal statuses, add an Issue with no Project status, depend on PR metadata validity, query `reviewDecision`, reconstruct review rounds, look up pull requests from Issues, or run a scheduled reconciler. + +[Issue lifecycle](../../../../.github/workflows/issue-lifecycle.yml) remains unsubscribed from `pull_request.ready_for_review`; neither event command depends on that action. [Issue policy](../../../../.github/workflows/issue-policy.yml) retains `ready_for_review` because it owns required-check enforcement when a human pull request enters review. + +## Verification + +[Issue-management tests](../../../../.github/issue-management/policy.test.mjs) pin the event-to-command mapping, the repeated-review-request transition after a changes-requested command, the changes-requested regression, terminal protection, and human override preservation. [Workflow tests](../../../../scripts/ci-workflow.spec.ts) pin the subscribed events, the changes-requested job condition, and the separate `ready_for_review` policy trigger. + +## Alternatives considered + +**Derive status from `reviewDecision` or a reconstructed review round.** GitHub's aggregate can remain `CHANGES_REQUESTED` after a repeated review request, while a round reducer introduces reviewer and ordering semantics beyond the two explicit handoffs. + +**Keep the forward-only projection.** Monotonic advancement protects later statuses, but it leaves an Issue in `In review` while the author is implementing requested changes. + +**Apply every review command unconditionally.** This is the smallest event handler, but it lets automation overwrite a human-owned Project status. The latest target-Project status actor therefore guards the only backward transition. + +**Restore `ready_for_review` or add a debounce queue.** Ready status carries neither review handoff, while another queue adds latency and control-plane state without changing either command. + +## Consequences + +A repeated review request moves an automation-managed resolving Issue to `In review` even while GitHub still reports an older blocking review. A later changes-requested review returns it to `In progress`; approval, comments, dismissal, pushes, and reviewer removal leave the most recent command's status unchanged. + +The projection remains event-driven and does not repair an event that never runs. Replaying an old workflow run can replay its old command, and ProjectV2 still provides no atomic compare-and-swap between the latest-state read and mutation. Per-pull-request workflow concurrency and the human-ownership guard reduce these races without introducing durable lifecycle state. diff --git a/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md new file mode 100644 index 0000000000..381a3f64a6 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md @@ -0,0 +1,41 @@ +# Agent Note: 由事件直接指定的 PR 评审状态命令 + +Status: implemented + +[English](2026-08-10-event-directed-pr-review-status.md) | 中文 + +## 问题 + +Issue 所在 Project 中的状态记录了解决工作的下一步由谁负责。PR(Pull Request)的汇总评审状态可以回答 GitHub 是否认为该 PR 可合并,却无法表示这次交接:作者修复代码并重新请求评审后,先前的 `CHANGES_REQUESTED` 评审仍可能继续生效。 + +单调投影也无法在评审人提出修改要求时,将由自动化管理的 Issue 从 `In review` 退回 `In progress`。重建评审轮次或评审人阻塞项会引入既定双事件约定并不需要的状态。 + +## 决策 + +Issue 生命周期工作流把评审 webhook 视为命令。`pull_request.review_requested`(包括重复请求)将目标状态指定为 `In review`。`pull_request_review.submitted` 将目标状态指定为 `In progress`,但仅在 `review.state` 为 `changes_requested` 时生效;submitted 事件仍不可省略,因为评审人即使没有先触发 review-request 事件,也可以直接提出修改要求。对于 approved 和 commented 提交,工作流会在生命周期作业创建 Project token 前跳过该作业;dismissed 评审则不在订阅范围内。 + +工作流订阅的普通 PR 事件仍是只向前推进的实现信号:它们可以将 `Inbox`、`Backlog` 或 `Ready` 推进至 `In progress`,但不能让 `In review` 倒退。请求评审命令可将任意较早的活跃状态推进至 `In review`。请求修改命令可将较早的活跃状态推进至 `In progress`;它也可以让 `In review` 状态回退,但仅在目标 Project 的最新状态事件由配置的生命周期执行主体写入时进行。若最新状态事件的执行主体是人工用户或未知主体,则保留当前状态。 + +处理器仅解析同一仓库内严格匹配的 `Fixes`、`Closes` 或 `Resolves` 引用。它不会更改终态、将没有 Project 状态的 Issue 添加到 Project、依赖 PR 元数据是否有效、查询 `reviewDecision`、重建评审轮次、从 Issue 反向查找 PR,或运行定时协调器。 + +[Issue 生命周期](../../../../.github/workflows/issue-lifecycle.yml)仍不订阅 `pull_request.ready_for_review`;两条事件命令均不依赖该动作。[Issue 策略](../../../../.github/workflows/issue-policy.yml)保留 `ready_for_review`,因为人工提交的 PR 进入评审时,该工作流负责执行必需检查门禁。 + +## 验证 + +[Issue 管理测试](../../../../.github/issue-management/policy.test.mjs)锁定事件到命令的映射、请求修改命令后重复请求评审所触发的状态转换、请求修改后的状态回退、终态保护,以及保留人工覆盖状态。[工作流测试](../../../../scripts/ci-workflow.spec.ts)锁定订阅事件、请求修改作业的条件,以及独立的 `ready_for_review` 策略触发器。 + +## 考虑过的替代方案 + +**根据 `reviewDecision` 或重建的评审轮次派生状态。** GitHub 的汇总状态在重复请求评审后仍可能保持为 `CHANGES_REQUESTED`,而轮次归约器会引入超出两个显式交接动作所需范围的评审人语义和顺序语义。 + +**保留只向前推进的投影。** 单调推进可保护较后的状态不被回退,但作者正在按要求修改代码时,Issue 会一直停留在 `In review`。 + +**无条件应用每条评审命令。** 这是最精简的事件处理器,但会让自动化覆盖由人工管理的 Project 状态。因此,处理器通过目标 Project 最新状态事件的执行主体保护唯一允许的回退转换。 + +**恢复 `ready_for_review` 或添加防抖队列。** Ready 状态并不表示两种评审交接中的任何一种;新增队列只会增加延迟和控制平面状态,不会改变任一命令。 + +## 后果 + +即使 GitHub 仍报告一个较早的阻塞性评审,重复请求评审也会将正由当前 PR 解决且由自动化管理的 Issue 推进至 `In review`。后续提出修改要求的评审会将其退回 `In progress`;批准、评论、撤销评审、推送和移除评审人都不会改变最近一条命令设定的状态。 + +投影仍由事件驱动;如果某个事件从未触发工作流运行,投影不会自行修复。回放旧的工作流运行可能会再次执行其中的旧命令;ProjectV2 仍不提供在读取最新状态与执行变更之间进行原子比较并交换(compare-and-swap)的能力。以单个 PR 为粒度的工作流并发控制和人工状态所有权保护机制可减少这些竞态,而无需引入持久化生命周期状态。 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml new file mode 100644 index 0000000000..52d911bb5f --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.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-10-npm-release-sequences.md +2026-08-10-npm-release-sequences.md: df81756ab84163b21996b5e2f12c5c8db994d9a5 +2026-08-10-npm-release-sequences.zh.md: 03269aeb987509034564bd0cac92f5d26c7f9b58 diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md new file mode 100644 index 0000000000..df81756ab8 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -0,0 +1,162 @@ +# Agent Note: Private npm publication as three independent sequences + +Status: implemented + +English | [中文](2026-08-10-npm-release-sequences.zh.md) + +## Problem + +This repository held three unrelated groups of publishable packages and no channel that sent any of them to a registry. + +`packages/*/*` and `apps/*` form the runtime surface of `@deepseek-ai/dsh`; `vendor/*` holds nine rescoped Cordis framework packages, each carrying its upstream version; `native/landlock-run/packages/*` holds Linux platform packages with their own workflow. The three differ in version baseline, change rate, and build requirements: dsh moves with the product, vendor moves only when upstream is re-synced or a local modification changes, and native needs a musl toolchain and one build per architecture. Forcing them through one pipeline means every product release republishes the framework and the native binaries. + +Two hard blockers sat in the way. All 217 workspace manifests set `private: true`, which npm refuses to publish. The subtler one was 933 hand-written `peerDependencies: "^0.0.1"` entries between sibling dsh packages: `pnpm pack` substitutes the `workspace:` protocol but leaves semver ranges alone, and `^0.0.1` means `>=0.0.1 <0.0.2` — it excludes `0.0.2`, and semver excludes prereleases from a range without a prerelease of its own, so it excluded `0.0.1-rc.1` too. Those entries never failed only because the version never left `0.0.1`. + +`scripts/publish-npm-baseline.ts` is a local publication script: it packs and publishes in one process, needs a human to authenticate and retry on their own machine, and excludes vendor from its release set. It cannot be the basis for CI publication, though its tarball payload validation and installed-artifact probes are verified parts. + +## Decision + +### Three independent sequences + +`packages/`, `vendor/`, and `native/` each have one bump sequence and one publication, sharing no version, no trigger, and no waiting. Releasing dsh does not republish vendor; releasing vendor does not republish native. + +| Sequence | Members | Version baseline | Tag | Workflow | +|---|---|---|---|---| +| dsh | `packages/*/*` + `apps/*` (`@deepseek-ai/dsh` and `@deepseek-ai/dsh-frontend`) | one version for the family and the workspace root, `0.0.x` | `dsh-v` | `release.yml` | +| vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor--v` (one per package) | `release-vendor.yml` | +| native | `native/landlock-run/packages/*` | its own `0.0.x` | `landlock-run-v` | `landlock-run-release.yml` | + +All three publish privately to the `@deepseek-ai` scope on npmjs.com. `publishConfig.access` in each manifest is `restricted` and no workflow passes `--access`, because a command-line flag overrides the manifest. + +### Versions land in the repository from a local command; CI only checks and uploads + +Each sequence has one bump-and-commit command: it derives the target version, writes it into the relevant manifests, runs `pnpm install --lockfile-only`, and commits the manifests with the lockfile. The published version is therefore readable from the repository. A human creates the tag after the commit merges to master; CI never writes to the repository and needs no write permission. + +`release:dsh` accepts `major`, `minor`, `patch`, or an explicit version, and writes one version across the family **and the workspace root** — the workspace constraint requires every member's version to equal the root's, so the root carries the family version, and the root check accepts a prerelease segment. A prerelease such as `0.0.1-rc.1` drives pack, the installed-artifact probe, and one real private publication before numbered versions follow. The dist-tag decision is the one `landlock-run-release.yml` already made: a version with a prerelease segment publishes under `--tag next`, anything else takes `latest`. + +### vendor: publish what changed, and let tags be the ledger + +The vendored packages are decoupled from upstream by their scope but keep their own version lines. The published version is the higher of the manifest version and the last published version, with the patch incremented — which also drops an upstream prerelease segment. The first published versions: + +| Package | Upstream version | First published version | +|---|---|---| +| `@deepseek-ai/cordis` | 4.0.0-rc.7 | 4.0.1 | +| `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | 1.0.1 | +| `@deepseek-ai/cosmokit` | 1.8.1 | 1.8.2 | +| `@deepseek-ai/schemastery` | 3.18.0 | 3.18.1 | +| `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | 1.0.16 | +| `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 1.0.5 | +| `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | 1.1.3 | +| `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 1.0.1 | +| `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 1.0.1 | + +Taking the last published version as the baseline is what survives a re-sync: upstream restoring `4.0.0-rc.8` after this repository published `4.0.1` would otherwise compute `4.0.1` again and collide. `--prerelease rc.1` publishes a rehearsal instead, which takes `--tag next` and leaves the release numbers free: a prerelease has lower precedence than the release it precedes, so `4.0.1` still follows `4.0.1-rc.1`. That ordering is computed here rather than read from `git tag --sort=v:refname`, which places a prerelease above its release. + +Only changed packages publish, and the change judgement adds no state file: **each package has its own tag, and that tag records the commit it last published from**. For each package, bump reads the newest `vendor--v*` tag and diffs the package directory against it. A path counts when the manifest's `files` selects it, when npm publishes it regardless (`package.json`, `README*`, `LICENSE*`), or — for a package whose `files` selects `lib/` — when it is a build input (`src/**`, `tsconfig*.json`, a build config). That last rule exists because a built payload is not tracked by git: without it, a real source change reads as "nothing changed" and the next publication fails on a version whose bytes moved. + +A tag is a commit pointer, not proof of publication. Bump asks the registry whether the version its newest tag names exists and fails for a human to resolve when it does not, because a tag pushed for a publication that then failed would otherwise read as "already published" and skip the package indefinitely. Querying a private package needs credentials, so an unauthenticated machine reports the gap instead of failing. + +`vendor/cordis` publishes `src` as well. Its export map declares `"./src/*"`, so a tarball without those files points consumers at absent paths, and `files` selecting only build output left the change judgement with no tracked path to match. + +### Publication runs only on GitHub, and the registry decides what goes out + +Publication runs only from GitHub Actions; there is no local publication path. Publish reads no tag and no manifest of "what this release includes". For each packed tarball it compares the version against the registry, in three states: + +| State | Action | +|---|---| +| the registry does not have that version | publish | +| the registry has it, and the tarball's sha512 equals the recorded `dist.integrity` | skip: this is a re-run over one artifact | +| the registry has it, and the integrity differs | fail, reporting content changed without a version bump | + +The third state catches code that changed without a version bump. The first two provide idempotence — re-running publish over one artifact republishes nothing and needs no manual selection of packages. The same rule resolves the tension between one vendor release carrying several tags and a workflow that can only run from one ref: the workflow never infers which packages to publish from the tag it ran from. + +### Workspace-internal references use the `workspace:` protocol + +Every reference to a workspace member uses `workspace:^`, so `pnpm pack` substitutes a range matching the target version: sibling `peerDependencies` follow the family version, and a reference to a vendored package follows that package's own line. The Landlock platform packages keep `workspace:*`, which publishes the exact version, because a platform package and its entry must agree exactly. + +`scripts/check-workspace-constraints.ts` requires the protocol, so a new package cannot reintroduce a hand-written range; the invariant-companion rule requires `workspace:^` for `@deepseek-ai/dsh-invariants` for the same reason. + +### Release family objects + +The entity in this domain is a **release family**: a set of packages sharing one version baseline and tag naming that publishes as a unit. Adding a family means adding a subclass and a workflow lane, not changing the core. + +| Object | Responsibility | +|---|---| +| `ReleaseFamily` | a family's identity: member discovery, version baseline, tag prefix, packed-payload rule, installed entry | +| `ReleaseMember` | one publishable package: directory, name, version, manifest | +| `publishOrder` | topological order over runtime dependencies, ties broken by package name; a cycle is reported rather than resolved arbitrarily | +| `pack` | packs a whole family into one directory and records the upload order | +| `verify` | the family's version baseline, and — when publishing — that the run comes from that family's tag and its members are publishable | +| `verify-packed-install` | installs the tarballs of one or more pack directories into a throwaway consumer and drives the installed executable | +| `publish` | the three registry states above | +| `process` / `tarball` | the one home for spawning commands and for reading a packed tarball, including the entry guard that keeps every script importable | + +The dsh family applies the repository's publication payload policy, which rejects sources and declaration maps. The vendored family keeps upstream's payload, because those manifests export `./src/*` and dropping `src` would publish an export map pointing at absent files. + +### Workflow shape: pack everything at once, then publish as one set + +The `pack` job walks the whole release set once, packing each member into one directory, writes the upload order, and uploads that directory as one artifact; the `publish` job downloads that artifact and publishes each entry in order. The release set is one unit — half the packages can never reach the registry while the other half is still building. + +`pack` carries no credentials and runs on every pull request and master push, so a pull request proves the release set still packs. `publish` is a manual dispatch, sits behind the `npm-publish` environment for human approval, and neither builds nor rebuilds — it uploads the bytes pack produced. Pack runs are grouped per ref so concurrent pull requests do not displace each other; the publish job carries the global group, because dist-tags are shared registry state. + +A dsh verification installs the vendored family's pack output too. The harness packages declare the vendored framework as a peer, those packages live in another sequence, and the credential-free job cannot fetch them from a private registry — so `release.yml` packs the vendored family for verification while publishing only its own set. + +The verification also packs the Landlock entry, which `dsh-sandbox-local` declares as a plain dependency, and omits optional dependencies. The platform packages behind those optional entries need a musl toolchain and one build per architecture, so a job on one runner cannot produce them; a consumer that cannot install them must still start, which is what optional means here. The verification therefore reads a directory by its contents rather than a pack order, because a directory can hold tarballs packed only to satisfy a cross-sequence dependency. + +### Repository changes this carried + +| Item | Content | +|---|---| +| release-set manifests | `private: true` removed; `publishConfig.access: restricted` and `repository` with each package's `directory` added | +| release-set boundary | every member of `packages/*/*`, `apps/*`, and `vendor/*` | +| dependency protocol | workspace-internal references are `workspace:^`, with `check-workspace-constraints.ts` and the invariant-companion rule requiring it | +| root `AGENTS.md` | the convention that vendored packages are `private: true` no longer holds | +| `vendor/README.md` | records `src` joining `cordis`'s `files` as a local modification | +| the three native packages | `publishConfig.access: restricted`, and their workflow no longer passes `--access` | + +### Relationship to the earlier proposal + +This Agent Note replaces the version scheme and the release-set boundary in [artifact-first npm baseline publication](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md): its `--` prerelease versions and `dev-` dist-tag are not adopted, and vendor is not excluded from the release set. What both agree on stands: pack and publish are separate, publish consumes only verified tarballs, and the payload and installed-artifact probes are release gates. + +## Alternatives considered + +**A `--` version.** Planned for continuous dev publication. It conflicts with keeping the published version in the repository: the version embeds a commit SHA, and writing the version back produces a new commit, so the SHA can only name the parent commit that was published and the link needs a convention to explain it. With numbered versions, a prerelease such as `0.0.1-rc.1` already covers "verify first, then release". + +**A `vendor/published.json` ledger recording each package's published version and commit.** This preceded the tag design. It adds a state file that must not drift from the registry. A per-package tag gives the same commit pointer, and the tag has to exist anyway, so it introduces no second copy of the state. + +**Event-level tags (`vendor-r1`, `vendor-r2`).** Prepared for one release event carrying several package versions. Once the registry decides what publishes, the workflow no longer infers the set from the tag, so per-package tags suffice — and each one names its own package's real version. + +**Putting the nine vendored packages on one `4.0.x` line.** It removes change detection, but cosmokit would jump from `1.8.1` to `4.0.1` and lose its upstream lineage; the upstream ranges inside the nine (`^1.8.1` and friends) would stop matching immediately, forcing a rewrite of the vendored manifests. + +**Incrementing every vendored package on every vendor release, with no change detection.** The least machinery, at the cost of new version numbers for packages whose content is byte-identical to the previous release. Tags reduce change detection to reading one tag and running one diff, which is not worth trading for inflated version numbers. + +**Deciding "already published" from the version alone, without comparing content.** The reference flow queries no registry: publish uploads each tarball and npm rejects a duplicate version. Skipping on the version alone misses code that changed without a bump, which is the only failure that quietly leaves stale bytes on the registry. The cost is a registry query and a dependency on reproducible builds. + +**Verifying only the packed install, with no local registry.** The reference flow unpacks tarballs into a tree and drives it with plain Node, which bypasses version-range resolution. Running a local registry in CI to cover that layer was rejected: artifact correctness is covered by existing tests, the publication path is exercised by the master rehearsal, and a pull request only needs to prove the release set packs. Installing from `file:` specifiers still exercises range resolution for every internal dependency. + +**Selecting a subset by entry closure.** Crawling `dependencies` from `@deepseek-ai/dsh` and `@deepseek-ai/dsh-frontend` yields 156 packages, 61 fewer than the whole set. But this repository's plugins are mounted by name from `cordis.yml` rather than imported: `vendor/cordis-plugin-group` and `vendor/cordis-plugin-logger-console` fall outside the dependency closure while being required at runtime. Selecting by code dependency fails as "the consumer installs it and it will not start", and it would need a standing proof that no mounted package was missed. Under a private scope the extra packages are invisible outside the organization. `python/`, the root `examples/`, `docs/`, and `website/` are not members. + +**Extending `scripts/publish-npm-baseline.ts`.** It is a local publication script that packs and publishes in one process, the opposite of separating credential-free packing from protected publication. Its verified parts — payload validation and installed-artifact probes — are reused so `pnpm run duplication` does not report clones. + +**One workflow with a `family` input.** Two version models in one file forks the concurrency group, the tag prefix, and the rehearsal triggers into conditional expressions. One file per family is both shorter and easier to read. + +**Rewriting dependency ranges at publication time.** Compared with the protocol, the rewrite runs only in CI, a local `pnpm install` cannot show whether it is correct, and it repeats on every release. + +**Running bump in CI and pushing the version back.** It needs repository write permission for the workflow, and a version commit on the release branch races human commits. Bump and commit stay local; CI checks and uploads. + +## Consequences + +The release scripts are importable modules behind a guarded entry point, and their judgements carry unit tests: tag naming, publish order and cycle reporting, version-baseline arithmetic, the payload change judgement, and each family's payload policy. Two defects the first draft carried — a publish command that ran the pack command on import, and a change judgement blind to `vendor/cordis` source edits — are exactly what a test at that seam catches. + +A pull request runs the full pack for both sequences without credentials and installs the packed dsh tarballs into a throwaway consumer, where plain Node drives `dsh --version`. That probe is deliberately one command: it proves `files` selected a complete payload and that the published ranges resolve, and says nothing about interactive behavior. + +What this costs: + +- **Tags can drift from the registry.** A tag pushed for a publication that then failed is caught by bump's registry check, but only where credentials exist; an unauthenticated machine reports the gap and continues. +- **The change judgement depends on visible tags.** A shallow clone, or a checkout without tags, degrades the vendored judgement to "publish everything for the first time". `fetch-depth: 0` is a precondition, not an optimization. +- **The protocol rewrite touched 1504 dependency declarations.** It does not change local resolution — pnpm already resolves from the workspace — but it changes the ranges that go out. +- **Private packages need credentials to install.** Every consumer — CI, sandbox e2e, outside users — needs scope credentials, including for the Landlock packages, which have never been published and so cut off no existing anonymous path. +- **`repository` names a different organization than the one running the workflows.** Token-based publication is unaffected; npm provenance (OIDC) requires the two to agree, so adopting it means either repointing `repository` or publishing from the organization it names. +- **Byte reproducibility is assumed, not measured.** The skip-on-identical-integrity state rests on packing the same commit twice producing the same bytes. Nothing measures that yet: if the build embeds absolute paths or timestamps, a re-run reports a false failure. Measure it before the first publication a re-run might follow, and fall back to comparing per-file content hashes if it does not hold. +- **Re-running publish over an older artifact can move `latest` backwards.** Publication is decided per version, so an older set republished after a newer one takes the stable dist-tag again. The rehearsals run from a prerelease version, which never takes `latest`. +- **The first publication is one large step.** Nine vendored packages and the whole dsh set publish at once, so any payload defect surfaces in a single release, which is why a prerelease version drives the complete path first. diff --git a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md new file mode 100644 index 0000000000..03269aeb98 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -0,0 +1,162 @@ +# Agent Note: 三条独立序列的私有 NPM 发布 + +Status: implemented + +[English](2026-08-10-npm-release-sequences.md) | 中文 + +## 问题 + +这个仓库有三组互不相干的可发布包,却没有任何发布通道把它们送上 registry。 + +`packages/*/*` 与 `apps/*` 组成 `@deepseek-ai/dsh` 的运行面;`vendor/*` 是九个 rescope 过的 Cordis 框架包,各自带着上游的版本号;`native/landlock-run/packages/*` 是 Linux 平台包,有自己的 workflow。三组的版本基线、变更节奏和构建要求都不同:dsh 随产品迭代,vendor 只在同步上游或改动本地修改时才动,native 需要 musl 工具链和逐架构构建。把它们塞进一条发布流水线,等于每次产品发版都要重发框架和原生二进制。 + +挡路的还有两处硬门。全部 217 个 workspace manifest 都是 `private: true`,`npm publish` 直接拒绝。更隐蔽的是 933 条 dsh 兄弟包之间硬写的 `peerDependencies: "^0.0.1"`:`pnpm pack` 只替换 `workspace:` 协议,不动语义范围,而 `^0.0.1` 等于 `>=0.0.1 <0.0.2`——发 `0.0.2` 落不进去,发 `0.0.1-rc.1` 也落不进去(semver 规定不带预发布段的范围排除预发布版本)。这些条目至今没出事,只因为版本一直停在 `0.0.1`。 + +`scripts/publish-npm-baseline.ts` 是本机发布脚本:它把 pack 与 publish 放进同一个进程,需要人工在本机完成认证与重试,且把 vendor 排除在发布集之外。它不能作为 CI 发布的基础,但其中的 tarball payload 校验与已安装产物探针是验证过的零件。 + +## 决策 + +### 三条独立序列 + +`packages/`、`vendor/`、`native/` 各自一条 bump 序列、各自一次发布,不共享版本号、不共享触发、不互相等待。发 dsh 不重发 vendor,发 vendor 不重发 native。 + +| 序列 | 成员 | 版本基线 | tag | workflow | +|---|---|---|---|---| +| dsh | `packages/*/*` + `apps/*`(`@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-frontend`) | 全族与 workspace 根共用一个 `0.0.x` | `dsh-v<版本>` | `release.yml` | +| vendored framework | `vendor/*` 九个包 | 每包各自一条版本线 | `vendor-<包名>-v<版本>`(每包一个) | `release-vendor.yml` | +| native | `native/landlock-run/packages/*` | 自己的 `0.0.x` | `landlock-run-v<版本>` | `landlock-run-release.yml` | + +三组一律发到 npmjs.com 的 `@deepseek-ai` scope 下的私有包。每个 manifest 的 `publishConfig.access` 都是 `restricted`,且没有任何 workflow 传 `--access`——命令行选项会覆盖 manifest。 + +### 版本由本地命令写进仓库,CI 只核对与上传 + +每条序列有一条 bump-and-commit 命令:算出目标版本,写进相关 manifest,跑 `pnpm install --lockfile-only`,再把 manifest 连 lockfile 一起 commit。发布版本因此在仓库里查得到。tag 由人工在 commit 合入 master 后打;CI 不写仓库,也不需要写权限。 + +`release:dsh` 接受 `major`、`minor`、`patch` 或显式版本号,把同一个版本写进全族**以及 workspace 根**——workspace 约束要求每个成员的版本等于根版本,所以根承载族版本,而根的检查接受预发布段。像 `0.0.1-rc.1` 这样的预发布号先把 pack、已安装产物探针和一次真实私有发布跑通,数字版本随后。dist-tag 沿用 `landlock-run-release.yml` 已有的判定:版本带预发布段就 `--tag next`,否则进 `latest`。 + +### vendor:谁改了谁发版,tag 就是账本 + +vendor 九包加了 scope 之后与上游脱钩,但保留各自的版本线。发布版本取「manifest 版本」与「上次发布版本」中较高的那个,再递增 patch——这一步同时去掉上游的预发布段。首发版本: + +| 包 | 上游版本 | 首发版本 | +|---|---|---| +| `@deepseek-ai/cordis` | 4.0.0-rc.7 | 4.0.1 | +| `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | 1.0.1 | +| `@deepseek-ai/cosmokit` | 1.8.1 | 1.8.2 | +| `@deepseek-ai/schemastery` | 3.18.0 | 3.18.1 | +| `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | 1.0.16 | +| `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 1.0.5 | +| `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | 1.1.3 | +| `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 1.0.1 | +| `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 1.0.1 | + +以「上次发布版本」为基线才扛得住重同步:本仓发过 `4.0.1` 之后上游把版本恢复成 `4.0.0-rc.8`,只看 manifest 会再算出 `4.0.1` 并撞上已发版本。加 `--prerelease rc.1` 则发一次排练版:它进 `--tag next`,而且不占用那组数字——预发布的优先级低于它所先行的正式版,所以 `4.0.1` 仍然接在 `4.0.1-rc.1` 之后。这个次序由脚本自己算,不读 `git tag --sort=v:refname`——git 会把预发布排在正式版之前。 + +只发改动过的包,而变更判据不引入新的状态文件:**每包一个 tag,tag 就是「上次发布到哪个 commit」的记录**。bump 对每个包取最新的 `vendor-<包名>-v*` tag,拿包目录与它做 diff。一条路径算命中的条件是:manifest 的 `files` 选中它,或 npm 无论如何都会发布它(`package.json`、`README*`、`LICENSE*`),或者——当该包的 `files` 选中 `lib/` 时——它是构建输入(`src/**`、`tsconfig*.json`、构建配置)。最后那条规则的存在理由是构建产物不在 git 里:没有它,真实的源码改动会读成「没变化」,而下一次发布会在一个字节已变的版本上失败。 + +tag 只是 commit 指针,不是发布成功的证明。bump 会向 registry 核对「最新 tag 指向的版本是否真的存在」,不存在就明确失败交人处理——否则一个为失败发布而推的 tag 会被读成「已发布」,从此永远跳过该包。查询私有包需要凭据,因此未鉴权的机器只报告这道核对被跳过,不失败。 + +`vendor/cordis` 现在也发布 `src`。它的 exports 声明了 `"./src/*"`,tarball 里没有这些文件就等于把消费方指向不存在的路径;而 `files` 只选构建产物,也让变更判据没有任何受 git 跟踪的路径可匹配。 + +### 发布只在 GitHub 执行,由 registry 状态决定发什么 + +发布只从 GitHub Actions 执行,没有本机发布路径。publish 不读 tag、不读任何「本次发布包含什么」的清单,而是对每个打包好的 tarball 拿版本与 registry 比对,分三态: + +| 状态 | 处置 | +|---|---| +| registry 上没有该版本 | 发布 | +| 已有该版本,且 tarball 的 sha512 等于记录的 `dist.integrity` | 跳过:这是同一批产物的重跑 | +| 已有该版本,但 integrity 不同 | 失败退出,报「内容已变但版本未 bump」 | + +第三态拦住「改了代码却没 bump 版本」。前两态给出幂等——同一个 artifact 重跑 publish 不会重复发布,也不需要人工挑拣包。同一条规则还解决了「一次 vendor 发布携带多个 tag,而 workflow 只能从一个 ref 触发」的矛盾:workflow 从不从触发它的 tag 去推断该发哪些包。 + +### workspace 内部引用走 `workspace:` 协议 + +所有指向 workspace 成员的引用都用 `workspace:^`,由 `pnpm pack` 替换成匹配目标版本的范围:兄弟包的 `peerDependencies` 跟随族版本,指向 vendored 包的引用跟随那个包自己的版本线。Landlock 平台包保留 `workspace:*`(发布成精确版本),因为平台包与它的入口必须版本完全一致。 + +`scripts/check-workspace-constraints.ts` 要求这个协议,所以新包无法再引入硬写的范围;同理,invariant companion 规则要求 `@deepseek-ai/dsh-invariants` 用 `workspace:^`。 + +### 发布族对象 + +这个领域里的实体是**发布族**:一组共享版本基线与 tag 命名、可整体发布的包。新增一族等于加一个子类和一条 workflow lane,不改核心。 + +| 对象 | 职责 | +|---|---| +| `ReleaseFamily` | 一族的身份:成员发现、版本基线、tag 前缀、打包 payload 规则、已安装入口 | +| `ReleaseMember` | 一个可发布包:目录、包名、版本、manifest | +| `publishOrder` | 按运行时依赖的拓扑序,同层按包名排;遇到环是报错而不是随意定序 | +| `pack` | 把整族打进一个目录并记录上传顺序 | +| `verify` | 族的版本基线;发布时还要求本次运行来自该族的 tag、且成员可发布 | +| `verify-packed-install` | 把一个或多个 pack 目录的 tarball 装进一次性 consumer,并驱动已安装的可执行入口 | +| `publish` | 上面那三态 | +| `process` / `tarball` | 启动命令、读取打包 tarball 的唯一正家,其中的入口守卫让每个脚本都可被 import | + +dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。vendored 族保留上游 payload,因为那些 manifest 导出 `./src/*`,去掉 `src` 会发出一个导出映射指向不存在文件的包。 + +### workflow 形状:一次性 pack 全部,再统一 publish + +`pack` job 一趟遍历整个发布集,把每个成员打进同一个目录,写出上传顺序,整个目录作为一份 artifact 上传;`publish` job 下载那一份 artifact,按顺序逐个发布。发布集是一个整体——绝不会出现一半的包已经上了 registry、另一半还在构建。 + +`pack` 无凭据,在每个 pull request 和每次 master push 上跑,所以一个 pull request 就能证明发布集仍能完整打出来。`publish` 是手动 dispatch,挂在 `npm-publish` environment 后面等人工审批,且既不构建也不重建——它上传的就是 pack 产出的字节。pack 的 run 按 ref 分组,并发的 pull request 不会互相顶掉;全局分组落在 publish job 上,因为 dist-tag 是共享的 registry 状态。 + +dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 `release.yml` 为验证而打包 vendored 族,发布的仍只有自己那一份。 + +验证还会打一份 Landlock entry 的 tarball——`dsh-sandbox-local` 把它声明为普通 `dependencies`——同时略去可选依赖。那些可选项背后的平台包需要 musl 工具链且每个架构各构建一次,单台 runner 产不出来;而装不到它们的消费方也必须能起,这正是「可选」在这里的含义。因此验证按目录内容读取 tarball,而不是读发布顺序:一个目录可能只装着为满足跨序列依赖而打出来的包,任何发布顺序都不描述它。 + +### 本次带出的仓库改动 + +| 项 | 内容 | +|---|---| +| 发布集 manifest | 去掉 `private: true`;补 `publishConfig.access: restricted` 与带各自 `directory` 的 `repository` | +| 发布集边界 | `packages/*/*`、`apps/*`、`vendor/*` 的全部成员 | +| 依赖协议 | workspace 内部引用为 `workspace:^`,由 `check-workspace-constraints.ts` 与 invariant companion 规则强制 | +| 根 `AGENTS.md` | 「vendored 包是 `private: true`」这条约定不再成立 | +| `vendor/README.md` | 记录「`src` 加入 `cordis` 的 `files`」这条本地修改 | +| native 三包 | `publishConfig.access: restricted`,且其 workflow 不再传 `--access` | + +### 与先前提案的关系 + +本 Note 取代 [以产物为先的 NPM 基线发布](../../proposed/process/2026-08-04-artifact-first-npm-baseline-publication.md) 中的版本方案与发布集边界:那篇的 `-<时间戳>-<短 SHA>` 预发布版本与 `dev-` dist-tag 不再采用,vendor 也不排除在发布集之外。两篇一致的部分保留:pack 与 publish 分离、publish 只消费已验证的 tarball、payload 与安装后探针作为发布门。 + +## 曾考虑的替代方案 + +**`-<时间戳>-<短 SHA>` 版本号。** 曾计划用于持续 dev 发布。它与「把发布版本留在仓库里」冲突:版本内嵌 commit SHA,而把版本写回会产生新的 commit,于是 SHA 只能指向被发布的父 commit,这条链要靠约定解释。改用数字版本后,`0.0.1-rc.1` 这类预发布号已经覆盖「先验证再正式发」。 + +**用 `vendor/published.json` 账本记录每包的已发版本与 commit。** 这是 tag 方案之前的设计。它新增一份必须与 registry 不漂移的状态文件;per-package tag 提供同样的 commit 指针,而 tag 本来就要打,不引入第二处状态。 + +**事件级 tag(`vendor-r1`、`vendor-r2`)。** 为「一次发布事件携带多个包版本」准备。既然由 registry 决定发什么,workflow 就不再从 tag 推断集合,per-package tag 够用,而且每个 tag 携带的是它自己那个包的真实版本。 + +**把九个 vendored 包统一到一条 `4.0.x` 版本线。** 省掉变更检测,但 cosmokit 会从 `1.8.1` 跳到 `4.0.1`、丢失上游血缘;九包内部的上游范围(`^1.8.1` 之类)会立刻失配,必须改写 vendored manifest。 + +**每次 vendor 发布把九包全部 patch+1,不做变更检测。** 机制最少,代价是内容与上一版逐字节相同的包也拿到新版本号。tag 把变更检测的成本压到「读一个 tag、跑一次 diff」,不值得为省这点让版本号虚涨。 + +**只按版本号判断「是否已发布」,不比对内容。** 参照流程根本不查 registry:publish 逐个上传,重复版本由 npm 拒绝。只按版本号跳过会漏掉「改了代码没 bump」,而这是唯一会安静地把旧字节留在 registry 上的错误。代价是引入一次 registry 查询和对构建可复现性的依赖。 + +**只做打包后安装验证,不起本地 registry。** 参照流程是把 tarball 解包成一棵树、用普通 Node 驱动,这绕过了版本范围解析。曾提议在 CI 里起本地 registry 补这一层,被否:产物正确性已由既有测试覆盖,发布路径由 master 的排练覆盖,而 pull request 只需证明发布集能打出来。用 `file:` 说明符安装依然会对每个内部依赖走一遍范围解析。 + +**按入口闭包挑一部分包发。** 从 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-frontend` 沿 `dependencies` 爬得到 156 个包,比全量少 61 个。但本仓的插件是 `cordis.yml` 按名字挂载的、不是被 import 的:`vendor/cordis-plugin-group` 与 `vendor/cordis-plugin-logger-console` 落在依赖闭包之外,却是运行时必需。照代码依赖挑的失败形态是「消费方装完起不来」,而且要额外持续证明「没漏任何挂载项」。私有 scope 下多出来的包对组织外不可见。`python/`、根 `examples/`、`docs/` 与 `website/` 不是成员。 + +**在 `scripts/publish-npm-baseline.ts` 上扩展。** 它是本机发布脚本,把 pack 与 publish 放在同一进程,与「无凭据 pack、受保护 publish」的分离相反。它验证过的零件——payload 校验与已安装产物探针——被搬运复用,以免 `pnpm run duplication` 判重复。 + +**一个 workflow 用 `family` 输入选择序列。** 两套版本模型塞进一个文件,会让 concurrency 组、tag 前缀、排练触发条件全部分叉成条件表达式。一族一个文件更短也更好读。 + +**在发布期改写依赖范围。** 与协议相比,改写逻辑只在 CI 执行过,本机 `pnpm install` 看不出它是否正确,而且每次发布都要重来一遍。 + +**在 CI 里执行 bump 并把版本推回仓库。** 需要给 workflow 仓库写权限,且发布分支上的版本 commit 会与人的 commit 竞争。bump 与 commit 留在本地,CI 只核对与上传。 + +## 后果 + +发布脚本是带入口守卫的可 import 模块,其判断都有单测覆盖:tag 命名、发布顺序与环报告、版本基线运算、payload 变更判据,以及各族的 payload 策略。第一版带过的两个缺陷——publish 命令在 import 时执行了 pack 命令、变更判据对 `vendor/cordis` 的源码改动失明——正是这类测试在对应接缝上能抓住的。 + +一个 pull request 会为两条序列跑完整的 pack(无凭据),并把打包好的 dsh tarball 装进一次性 consumer,用普通 Node 驱动 `dsh --version`。这个探针刻意只有一条命令:它证明 `files` 选出了完整 payload、发布出去的范围可解析,不涉及任何交互行为。 + +代价: + +- **tag 可能与 registry 漂移。** 为失败发布而推的 tag 由 bump 的 registry 核对拦下,但只在有凭据的地方;未鉴权的机器只报告这道核对被跳过。 +- **变更判据依赖 tag 可见。** shallow clone 或未拉 tag 会把 vendored 族的判据退化成「全部首发」。`fetch-depth: 0` 是前提,不是优化。 +- **协议改写触及 1504 处依赖声明。** 它不改变本机解析(pnpm 本来就从 workspace 解析),但改变了发布出去的范围写法。 +- **私有包需要凭据才能安装。** 任何消费方——CI、沙箱 e2e、外部使用者——都要持有 scope 凭据,Landlock 三包也在其中;它们从未发布过,所以没有切断既有的匿名安装路径。 +- **`repository` 指向的组织与运行 workflow 的组织不同。** 用 token 发布不受影响;npm provenance(OIDC)要求二者一致,届时要么把 `repository` 改指过去,要么从它指向的组织发布。 +- **字节可复现性是假定的,没有实测。** 「integrity 相同则跳过」这一态建立在「同一 commit 两次 pack 得到相同字节」之上。目前没有任何东西测量过它:若构建嵌入了绝对路径或时间,重跑会误报失败。在第一次可能被重跑的发布之前实测,若不成立就退到比对 tarball 内逐文件内容哈希。 +- **用较旧的 artifact 重跑 publish 会把 `latest` 拉回旧版。** 发布是按版本决定的,所以在较新版本之后重发较旧的一批,会让稳定 dist-tag 再次指向旧版。排练用的是预发布版本,它永远不占 `latest`。 +- **首发是一次大步。** 九个 vendored 包与整个 dsh 集一次发出,任何 payload 缺陷都会集中在同一次发布里暴露——这正是先用预发布版本把完整链路走一遍的理由。 diff --git a/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.i18n.yaml b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.i18n.yaml new file mode 100644 index 0000000000..46715efdcb --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.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-10-vendor-package-rescope.md +2026-08-10-vendor-package-rescope.md: f2a142cec4e3c28fae54af8063cd730931fa738b +2026-08-10-vendor-package-rescope.zh.md: 994064fc869e1ccd95609ec67010eae794ed1c48 diff --git a/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md new file mode 100644 index 0000000000..f2a142cec4 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md @@ -0,0 +1,51 @@ +# Agent Note: Rescope vendored Cordis into @deepseek-ai + +Status: implemented + +English | [中文](2026-08-10-vendor-package-rescope.zh.md) + +## Problem + +The nine packages under `vendor/` kept their upstream npm names (`cordis`, `cosmokit`, `schemastery`, `@cordisjs/plugin-*`). That premise does not survive publication: every harness package declares `cordis` as a peer dependency, so a consumer installing `@deepseek-ai/dsh-*` must resolve it from the registry, which means publishing the harness publishes this framework layer too. Publishing it under the upstream names squats them on the registry, and where that registry proxies npmjs, the same-name entries shadow the real upstream packages and install the wrong framework into unrelated projects. + +## Decision + +All nine packages move into the `@deepseek-ai` scope. Directory names, upstream version numbers, and dependency ranges stay untouched, so the `vendor/README.md` manifest still reads as an upstream snapshot. [docs/rescope.md](../../../../docs/rescope.md) restates this mapping for consumers. + +| Directory | npm name | Upstream name | +|---|---|---| +| `cordis/` | `@deepseek-ai/cordis` | `cordis` | +| `cosmokit/` | `@deepseek-ai/cosmokit` | `cosmokit` | +| `schemastery/` | `@deepseek-ai/schemastery` | `schemastery` | +| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | +| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | +| `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | +| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | +| `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | +| `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | + +The rewrite touches only **delimited, complete package-name tokens**: quoted or backticked specifiers (optionally with a `/subpath`), `package.json` names and dependency keys, `cordis.yml` `name:` values, and `tsconfig.base.json` `paths` keys. Identically spelled strings that are not package names therefore stayed as they were: the `cordis.yml` config-file family, the Loader's literal `cordis:` builtin prefix (`cordis:include`, `cordis:group` — see `vendor/loader/src/config/tree.ts`), kind strings like `cordis-config-entry`, `@deepseek-ai/dsh-tool-cordis`, Schemastery's upstream `Symbol.for('schemastery')` and `vendor:` metadata field, the `packages//` directory names in `GROUP_ORDER` (`scripts/gen-module-graph.ts`, `scripts/gen-doc-graphs.ts`), and the upstream install instructions in `vendor/*/README.md`. + +Two classes are invisible to a token rule and were renamed site by site. First, property access and unquoted object keys — `manifest.peerDependencies?.cordis`, and the manifest keys the scaffold generates in `npm-dependency-policy.ts` and `local-plugin-blueprint.ts` — where TypeScript cannot catch a stale `Record` key. Second, constants that carry the name as data: the vendored set in `check-workspace-constraints.ts`, the group/include names in `verify-cordis-config.ts`, the `declare module` target strings in `cordis-walk.ts`, `gen-scoped-events.ts`, and typert's `analyzer.ts`, and `alwaysBundle` in `app-boot/tsdown.config.ts`. + +Markdown splits along what a reader does with it. Every fence follows the rename regardless of its info string, because a fence is code they copy or configuration they mount — the `yaml` fences naming Loader plugins and the `ts ignore-check` fences beside compiled ones included. Prose follows it under `docs/`, where a tutorial sentence quoting a name teaches something this repository no longer resolves. Prose elsewhere — `vendor/*/README.md`, package READMEs, and `.agents/notes/` — keeps the names it was written with, both because it records what was true then and because the same spelling can mean something else: the Python SDK's `cordis` option, the unvendored `@cordisjs/plugin-http`, or an agent-preset id. + +## Consequences + +- No upstream name remains in the publication set. `publish-npm-baseline.ts` now requires every published package to be `@deepseek-ai/*` with no vendored exemption, so regressing the rename fails before packing. +- The `vendor/README.md` manifest table gains an upstream-name column; `gen-third-party-notices` parses six columns and renders that name into `THIRD_PARTY_NOTICES.md`, keeping MIT attribution pointed at each fork's origin rather than our scope. +- `pnpm-workspace.yaml` drops the `cordis` and `@cordisjs/plugin-loader` `minimumReleaseAgeExclude` entries, which can no longer be fetched from a registry, and `knip.json` drops the `@cordisjs/.+` ignore pattern that `@deepseek-ai/.+` already covers. +- Upstream sync follows the procedure in `vendor/README.md` with one added obligation in step 3: re-apply the rename over the copied sources with `pnpm run rescope-vendor --apply`, whose mapping and the table's two name columns must agree. +- **Returning to the official upstream packages** means applying that mapping in reverse — `pnpm run rescope-vendor --apply --reverse` — then restoring the two `minimumReleaseAgeExclude` entries and relaxing the publication-set assertion. It spans roughly 1300 files, so replay it with the script rather than by hand. + +`scripts/rescope-vendor.ts` owns the rename: the mapping, the delimited-token rule, the per-file exemptions where a name is a directory instead of a package, the exact edits above, and a `--check` mode asserting no residue, every exact edit landed, and idempotency, which the `hygiene` gate runs on every CI pass. A rebase replays it instead of resolving a 1300-file conflict, and an upstream change to one of the pinned sites fails the run loudly instead of being silently skipped. + +## Alternatives considered + +**Keep the upstream names and exclude `vendor/` from publication.** Rejected because every harness package declares `cordis` as a peer dependency, so an installed `@deepseek-ai/dsh-*` would have no resolvable framework. + +**Rename only at pack time.** Rejected because the published names would disagree with the source tree, every module specifier would have to be rewritten inside the publish path, and no local run could reproduce what was published. + +**Rename the `vendor/` directories and unify versions on the repository base version too.** Rejected because directory names are not publication identity — renaming them drags in project references, tsdown globs, and documentation paths for no gain — and a `0.0.1` version would no longer satisfy the preserved `^4.0.0-rc.7` ranges, so pnpm would look for a registry copy and `verify-vendored-links` would fail. + +**Rewrite prose outside `docs/` and historical Agent Notes as well.** Rejected because those record what was true when written, and a bare `cordis` there is as likely to be an SDK option name or a preset id as a package; `docs/rescope.md` carries the mapping for readers instead. diff --git a/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.zh.md b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.zh.md new file mode 100644 index 0000000000..994064fc86 --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.zh.md @@ -0,0 +1,51 @@ +# Agent Note: 把 vendored Cordis 重命名进 @deepseek-ai scope + +Status: implemented + +[English](2026-08-10-vendor-package-rescope.md) | 中文 + +## 问题 + +`vendor/` 下的九个包此前保留上游 npm 名(`cordis`、`cosmokit`、`schemastery`、`@cordisjs/plugin-*`)。这个前提在发布时不成立:每个 harness 包都把 `cordis` 声明成 peer dependency,装了 `@deepseek-ai/dsh-*` 的消费者必须能从 registry 解析到它,所以发布 harness 必然连带发布这一层框架。用上游名发布就是在 registry 上占用别人的名字;若该 registry 对 npmjs 做上游代理,本名条目还会遮蔽真正的上游包,把错误的框架装进无关项目。 + +## 决定 + +九个包统一改名进 `@deepseek-ai` scope。目录名、上游版本号、依赖 range 一律不动,所以 `vendor/README.md` 的清单仍然读作一份上游快照。面向使用者的映射表见 [docs/rescope.md](../../../../docs/rescope.md)。 + +| 目录 | npm 名 | 上游名 | +|---|---|---| +| `cordis/` | `@deepseek-ai/cordis` | `cordis` | +| `cosmokit/` | `@deepseek-ai/cosmokit` | `cosmokit` | +| `schemastery/` | `@deepseek-ai/schemastery` | `schemastery` | +| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | +| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | +| `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | +| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | +| `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | +| `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | + +改写只落在**带定界符的完整包名 token** 上:引号或反引号包裹的 specifier(可带 `/子路径`)、`package.json` 的 `name` 与依赖键、`cordis.yml` 的 `name:` 值、`tsconfig.base.json` 的 `paths` 键。因此以下同形串一律未改,它们不是包名:`cordis.yml` 及其家族文件名、Loader 的 `cordis:` 内建前缀(`cordis:include`、`cordis:group`,见 `vendor/loader/src/config/tree.ts`)、`cordis-config-entry` 这类 kind 串、`@deepseek-ai/dsh-tool-cordis`、Schemastery 上游的 `Symbol.for('schemastery')` 与 `vendor:` 元数据、`scripts/gen-module-graph.ts` 与 `gen-doc-graphs.ts` 里 `GROUP_ORDER` 的 `packages//` 目录名,以及 `vendor/*/README.md` 里的上游安装指引。 + +Token 规则看不见两类点位,它们按名字逐处改:一是属性访问与未加引号的对象键(`manifest.peerDependencies?.cordis`、脚手架 `npm-dependency-policy.ts` 与 `local-plugin-blueprint.ts` 生成的清单键)——TypeScript 抓不到过期的 `Record` 键;二是把名字当数据的常量(`check-workspace-constraints.ts` 的 vendored 集合、`verify-cordis-config.ts` 的 group/include 名、`cordis-walk.ts` 与 `gen-scoped-events.ts` 与 typert `analyzer.ts` 里识别 `declare module` 目标的字符串、`app-boot/tsdown.config.ts` 的 `alwaysBundle`)。 + +Markdown 按「读者拿它做什么」一分为二。围栏一律跟着改,不看 info string——围栏里是读者要照抄的代码或要挂载的配置,包括写着 Loader 插件名的 `yaml` 围栏和紧邻编译围栏的 `ts ignore-check` 围栏。散文只在 `docs/` 下跟着改:教程里引用某个名字的句子,教的是本仓已不解析的东西。`docs/` 之外的散文——`vendor/*/README.md`、各包 README、`.agents/notes/`——保留写作当时的名字:既因为它记录的是当时的事实,也因为同一个拼写可能指别的东西,比如 Python SDK 的 `cordis` 选项、我们没 vendor 的 `@cordisjs/plugin-http`,或某个 agent-preset 的 id。 + +## 影响 + +- 发布集里不再有任何上游名:`publish-npm-baseline.ts` 现在无条件要求每个待发包都是 `@deepseek-ai/*`,vendored 包不再豁免,改名一旦回退就会在打包前失败。 +- `vendor/README.md` 的清单表新增「上游名」列,`gen-third-party-notices` 随之解析六列并把上游名渲进 `THIRD_PARTY_NOTICES.md`;MIT 归属指向 fork 的来源,而不是我们的 scope。 +- `pnpm-workspace.yaml` 的 `minimumReleaseAgeExclude` 删去 `cordis` 与 `@cordisjs/plugin-loader` 两条:改名后这两个名字永远不从 registry 取。`knip.json` 的 `@cordisjs/.+` 忽略模式同理删除,已被 `@deepseek-ai/.+` 覆盖。 +- 上游 sync 照 `vendor/README.md` 的流程走,第 3 步多一项:对拷进来的源码重跑 `pnpm run rescope-vendor --apply`,脚本里的映射与清单表两列名字必须一致。 +- **要回到官方上游包**时反着跑这份映射——`pnpm run rescope-vendor --apply --reverse`——再补回 `minimumReleaseAgeExclude` 两条、放开发布集对 `@deepseek-ai/*` 的断言。改写量约 1300 个文件,用脚本重放而不是手改。 + +改名这件事由 `scripts/rescope-vendor.ts` 承载:映射、带定界符的 token 规则、名字其实是目录而非包时的逐文件豁免、上面那批精确改写,以及一个断言「零残留、每条精确改写都落上、幂等」的 `--check` 模式——它由 `hygiene` 门在每次 CI 上执行。rebase 时重放它,而不是去解一个 1300 文件的冲突;上游动了任一被钉住的点位,脚本会响亮失败而不是静默漏改。 + +## 考虑过的替代方案 + +**保留上游名,把 `vendor/` 排除在发布集之外。** 否决:每个 harness 包都声明 `cordis` 为 peer dependency,装好的 `@deepseek-ai/dsh-*` 会解析不到框架。 + +**只在打包时改名。** 否决:发出去的名字与源码树不一致,所有模块 specifier 得在发布路径里现改,本地也没有任何一次运行能复现发布出去的东西。 + +**目录名与版本号一并改。** 否决:目录名不是发布标识,改它会连带项目引用、tsdown glob 与文档路径,收益为零;版本号并入 `0.0.1` 后不再满足保留下来的 `^4.0.0-rc.7` range,pnpm 会转去 registry 找副本,`verify-vendored-links` 直接红。 + +**`docs/` 之外的散文与历史 Agent Note 一起改。** 否决:它们记录的是写作当时的事实,而且那里的裸 `cordis` 同样可能是 SDK 选项名或某个 preset id,未必是包;面向读者的映射由 `docs/rescope.md` 承载。 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml index dcef1b190f..b23fcae758 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md -2026-07-20-remove-stdio-and-echo-agents.md: 256e626f4ff41016d0227eef7cc3e4e51e15058b -2026-07-20-remove-stdio-and-echo-agents.zh.md: 013135eff5d1dbbc570561e70c751ff4de289989 +2026-07-20-remove-stdio-and-echo-agents.md: 23fcb90599c2ff96cd7ac0e6f7ee8fd508a6d1ad +2026-07-20-remove-stdio-and-echo-agents.zh.md: 7aabf5612a54245327da9af804f745237472cb88 diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md index 256e626f4f..23fcb90599 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.md @@ -19,7 +19,7 @@ The stdio and Echo agents are removed without compatibility packages, modes, com The remaining application roles are explicit: - `@deepseek-ai/dsh-tui` owns terminal-interactive execution. It rejects non-TTY streams before Loader boot; `apps/cli/config/base.cordis.yml` plus the `tui.cordis.yml` overlay own the complete coding composition, with PTY plus terminal-snapshot coverage in `apps/cli/tests/`. -- [`dsh run`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver. +- [`dsh --profile headless`](../../../../apps/cli/README.md) owns non-interactive execution. Its `headless` profile is the product composition; `examples/headless-agent` owns replay snapshots, generic real-agent suites, and an unexported keyless Loader driver. - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) and `@deepseek-ai/dsh-jsonrpc` own their framed protocol integrations. The SDK project model and create/config workflows replace the `stdio` run-interface option with `tui`; generated TUI projects compose `@deepseek-ai/dsh-tui` and create or resume one exact session. Repository-facing demo documentation requires a DeepSeek API key and leads with the real Headless or TUI agents. @@ -30,7 +30,7 @@ Keyless validation is test-owned. The Headless Loader smoke uses a fixture adapt TUI and Headless Loader coverage run the real app packages in source and built modes. PTY-driven subprocess coverage is reserved for the TUI lifecycle; other entry-point smokes use the one-shot pipe protocol. Headless proves its task/result and tool-call contracts. Generated graphs and repository searches reject stale package, command, leaf, SDK-interface, `createStdioChat`, and `StdioRuntime` references. -The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh run`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `examples/headless-agent/tests/headless.snapshot.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless example's test-only JSONL driver preserves assembled canonical-event snapshots without creating a second CLI contract. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the explicit Headless test composition for two ordered turns, while its package tests own finer elapsed-time behavior. +The built `dsh` bin rejects a piped TUI launch before Loader boot and points at `dsh --profile headless`; `apps/cli/tests/built-bin.e2e.ts` pins the product one-shot entry under plain Node, including output and invalid arguments. `examples/headless-agent/tests/headless.snapshot.ts` pins product persistence, while `apps/cli/tests/headless-shutdown.e2e.ts` owns bounded signal escalation. The headless example's test-only JSONL driver preserves assembled canonical-event snapshots without creating a second CLI contract. Code Mode has programmatic TUI snapshots and an ACP overlay demo. Time-context integration uses the explicit Headless test composition for two ordered turns, while its package tests own finer elapsed-time behavior. ## Alternatives considered diff --git a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md index 013135eff5..7aabf5612a 100644 --- a/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md +++ b/.agents/notes/implemented/simplification/2026-07-20-remove-stdio-and-echo-agents.zh.md @@ -19,7 +19,7 @@ DeepSeek Harness 在 TUI 和 Headless coding agent 之外,还提供了两个 保留的应用角色均有明确归属: - `@deepseek-ai/dsh-tui` 负责终端交互式执行。它会在 Loader 启动前拒绝非 TTY 流;`apps/cli/config/base.cordis.yml` 与 `tui.cordis.yml` overlay 拥有完整 coding 组装,PTY 与终端快照覆盖则位于 `apps/cli/tests/`。 -- [`dsh run`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。 +- [`dsh --profile headless`](../../../../apps/cli/README.md) 负责非交互式执行。其 `headless` profile 是产品组装;`examples/headless-agent` 负责回放快照、通用真实 agent 测试套件和未导出的无密钥 Loader driver。 - [`@deepseek-ai/dsh-acp-demo`](../../../../packages/examples/acp-demo/README.md) 和 `@deepseek-ai/dsh-jsonrpc` 负责各自的分帧协议集成。 SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换为 `tui`;生成的 TUI 工程组合 `@deepseek-ai/dsh-tui`,并创建或恢复一个确切会话。仓库中的演示文档要求 DeepSeek API key,并优先引导到真实的 Headless 或 TUI agent。 @@ -30,7 +30,7 @@ SDK 工程模型与 create/config 工作流将 `stdio` 运行接口选项替换 TUI 与 Headless 的 Loader 覆盖以源码和构建产物两种模式运行真实 app 包。由 PTY 驱动的子进程覆盖仅用于 TUI 生命周期;其他入口冒烟测试使用单次管道协议。Headless 验证任务/结果约定和工具调用约定。生成图谱与仓库搜索会拒绝陈旧的包、命令、叶节点、SDK 接口、`createStdioChat` 和 `StdioRuntime` 引用。 -构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh run`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`examples/headless-agent/tests/headless.snapshot.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 示例仅供测试的 JSONL driver 保留组装后的规范事件快照,而不会创建第二套 CLI(命令行界面)约定。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过显式的 Headless 测试组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 +构建后的 `dsh` 可执行文件会在 Loader 启动前拒绝通过管道启动 TUI,并指向 `dsh --profile headless`;`apps/cli/tests/built-bin.e2e.ts` 在普通 Node 下固定产品的一次性入口,包括输出和无效参数。`examples/headless-agent/tests/headless.snapshot.ts` 固定产品持久化,`apps/cli/tests/headless-shutdown.e2e.ts` 则负责有界信号升级。headless 示例仅供测试的 JSONL driver 保留组装后的规范事件快照,而不会创建第二套 CLI(命令行界面)约定。Code Mode 由程序化 TUI 快照与 ACP overlay demo 覆盖。时间上下文集成通过显式的 Headless 测试组装执行两个有序轮次,而更细粒度的耗时行为由时间上下文的包级测试负责。 ## 曾考虑的替代方案 diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml index 217265168d..e72d9beaf3 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md -2026-08-08-remove-cli-demo.md: 403e01f94c976d2d17eb391830721b31675cd6a9 -2026-08-08-remove-cli-demo.zh.md: 7f11e0c17a15454b99b32d14ea6eda177f4b01f6 +2026-08-08-remove-cli-demo.md: f8bc09825797f2165c14a75e5841d74b2e1bc1af +2026-08-08-remove-cli-demo.zh.md: 1044dd136888e33fc9935cf4e24cd3100b41b41f diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md index 403e01f94c..f8bc098257 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.md @@ -6,29 +6,29 @@ English | [中文](2026-08-08-remove-cli-demo.zh.md) ## Problem -After [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. +After [`dsh --profile headless`](../architecture/2026-08-06-app-owned-command-line.md) became the product one-shot command, `@deepseek-ai/dsh-cli-demo` remained a second application package for the same job. It carried another executable, argument grammar, app composition, cancellation lifecycle, text/JSON/stream-JSON output contract, built artifact, documentation surface, and test suite. The two entry points also assembled different trees, so a successful demo did not prove the shipped `headless` profile and users had to choose between overlapping commands. The replay suites still need canonical session events to pin assembled backend behavior. That testing need does not require a published command or compatibility contract. ## Decision -Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. The root `demo:headless` script is retained only as a direct alias of `dsh run`; the product command owns final-text stdout, the observation URL on stderr, persistence, exit status, and shutdown. +Delete `@deepseek-ai/dsh-cli-demo` completely: its package, bin, parser, app plugin, output formats, tests, workspace references, generated-catalog entries, and active documentation. No alias or compatibility package remains. Source users invoke the product command through `pnpm dsh --profile headless`; it owns final-text stdout, failure diagnostics on stderr, persistence, exit status, and shutdown. `examples/headless-agent` becomes an explicit test composition. Its Loader configs mount `@deepseek-ai/dsh-agent-spine-demo`, one root agent, JSONL persistence, and checkpoint policy as separate rows instead of hiding them behind an app bundle. The support-tier `@deepseek-ai/dsh-loader-smoke` package owns the shared direct-agent turn helper; unexported example-local drivers select their Loader configuration and render canonical events as JSONL. They are launched only by tests, have no bin, and do not define a supported product output format. ## Alternatives considered -- **Keep `dsh-cli-demo` as an alias or wrapper around `dsh run`.** Rejected because a second bin and package would preserve two discoverable owners without adding capability. -- **Move JSON and stream-JSON flags onto `dsh run`.** Rejected because no current product consumer requires them; adopting the old demo protocol would enlarge the canonical CLI contract solely to save test machinery. +- **Keep `dsh-cli-demo` as an alias or wrapper around `dsh --profile headless`.** Rejected because a second bin and package would preserve two discoverable owners without adding capability. +- **Move JSON and stream-JSON flags onto `dsh --profile headless`.** Rejected because no current product consumer requires them; adopting the old demo protocol would enlarge the canonical CLI contract solely to save test machinery. - **Delete the canonical-event snapshots with the package.** Rejected because they pin model-visible assembled behavior that final-text product acceptance cannot observe. - **Keep the app plugin but delete only its bin.** Rejected because the hidden composition would still duplicate the explicit headless profile and conceal which services the test leaf mounts. ## Consequences -This is intentionally breaking. `dsh-cli-demo`, its `--output-format` choices, and imports from `@deepseek-ai/dsh-cli-demo/src/cli.ts` no longer resolve. There is no public event-stream replacement in this change; callers use `dsh run` for one-shot execution and must choose an existing protocol surface when they need structured automation. +This is intentionally breaking. `dsh-cli-demo`, its `--output-format` choices, and imports from `@deepseek-ai/dsh-cli-demo/src/cli.ts` no longer resolve. There is no public event-stream replacement in this change; callers use `dsh --profile headless` for one-shot execution and must choose an existing protocol surface when they need structured automation. -The repository retains backend replay coverage through test-only infrastructure, while product smoke and built-bin acceptance exercise `dsh run`. A separate one-shot package may return only if it owns a genuinely independent, versioned protocol that cannot belong to the product launcher; a second spelling or output shim is not enough. +The repository retains backend replay coverage through test-only infrastructure, while product smoke and built-bin acceptance exercise `dsh --profile headless`. A separate one-shot package may return only if it owns a genuinely independent, versioned protocol that cannot belong to the product launcher; a second spelling or output shim is not enough. ## Verification -Focused Loader smokes cover the explicit composition in source and plain-Node built modes, snapshot tests diff its canonical JSONL and persisted logs, product acceptance covers `dsh run`, and documentation plus generated graph/catalog gates reject live references to the removed package. The frozen Agent Note archive remains historical evidence and is not rewritten. +Focused Loader smokes cover the explicit composition in source and plain-Node built modes, snapshot tests diff its canonical JSONL and persisted logs, product acceptance covers `dsh --profile headless`, and documentation plus generated graph/catalog gates reject live references to the removed package. The frozen Agent Note archive remains historical evidence and is not rewritten. diff --git a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md index 7f11e0c17a..1044dd1368 100644 --- a/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md +++ b/.agents/notes/implemented/simplification/2026-08-08-remove-cli-demo.zh.md @@ -6,29 +6,29 @@ Status: implemented ## 问题 -在 [`dsh run`](../feature/2026-08-08-dsh-run-headless-command.md) 成为产品的一次性命令后,`@deepseek-ai/dsh-cli-demo` 仍是承担同一工作的第二个应用包。它另行拥有一套可执行文件、参数语法、应用组装、取消生命周期、文本/JSON/stream-JSON 输出约定、构建产物、配套文档和测试套件。两个入口组装的树也不相同,因此 demo 成功不能证明已交付的 `headless` profile 可用,用户还必须在功能重叠的命令之间作出选择。 +在 [`dsh --profile headless`](../architecture/2026-08-06-app-owned-command-line.md) 成为产品的一次性命令后,`@deepseek-ai/dsh-cli-demo` 仍是承担同一工作的第二个应用包。它另行拥有一套可执行文件、参数语法、应用组装、取消生命周期、文本/JSON/stream-JSON 输出约定、构建产物、配套文档和测试套件。两个入口组装的树也不相同,因此 demo 成功不能证明已交付的 `headless` profile 可用,用户还必须在功能重叠的命令之间作出选择。 回放套件仍需要规范会话事件来固定组装后的后端行为。这一测试需求不需要已发布命令或兼容性约定。 ## 决策 -彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。根目录的 `demo:headless` 脚本仅作为 `dsh run` 的直接别名保留;stdout 上的最终文本、stderr 上的观察 URL、持久化、退出状态和关闭行为均由产品命令负责。 +彻底删除 `@deepseek-ai/dsh-cli-demo`:包括它的包、bin、解析器、应用插件、输出格式、测试、workspace 引用、生成目录条目和现行文档。不保留别名或兼容包。源码用户通过 `pnpm dsh --profile headless` 调用产品命令;stdout 上的最终文本、stderr 上的失败诊断、持久化、退出状态和关闭行为均由该命令负责。 `examples/headless-agent` 成为显式测试组装。其 Loader 配置把 `@deepseek-ai/dsh-agent-spine-demo`、一个根 agent(智能体)、JSONL 持久化和检查点策略挂载为独立配置行,不再将其隐藏在应用组合包之后。支持层的 `@deepseek-ai/dsh-loader-smoke` 包负责共享的直接 agent 轮次 helper;未导出的示例本地 driver 选择各自的 Loader 配置,并将规范事件渲染为 JSONL。这些 driver 只由测试启动,不提供 bin,也不定义受支持的产品输出格式。 ## 考虑过的替代方案 -- **保留 `dsh-cli-demo` 作为 `dsh run` 的别名或包装层。** 不予采纳:第二个 bin 和包会让同一功能继续存在两个可发现的归属方,却没有增加任何能力。 -- **把 JSON 和 stream-JSON 标志移到 `dsh run`。** 不予采纳:当前没有产品消费方需要这些标志;沿用旧 demo 协议,只会为了保留测试机制而扩大规范 CLI(命令行界面)约定。 +- **保留 `dsh-cli-demo` 作为 `dsh --profile headless` 的别名或包装层。** 不予采纳:第二个 bin 和包会让同一功能继续存在两个可发现的归属方,却没有增加任何能力。 +- **把 JSON 和 stream-JSON 标志移到 `dsh --profile headless`。** 不予采纳:当前没有产品消费方需要这些标志;沿用旧 demo 协议,只会为了保留测试机制而扩大规范 CLI(命令行界面)约定。 - **随包一并删除规范事件快照。** 不予采纳:这些快照固定了模型可见的组装行为,而只检查最终文本的产品验收无法观察这些行为。 - **保留应用插件,只删除它的 bin。** 不予采纳:隐藏的组装仍会重复显式的 headless profile,并掩盖测试叶节点挂载了哪些服务。 ## 后果 -这是有意为之的破坏性变更。`dsh-cli-demo`、它的 `--output-format` 选项以及对 `@deepseek-ai/dsh-cli-demo/src/cli.ts` 的导入都不再可解析。本变更不提供公开的事件流替代接口;调用方使用 `dsh run` 执行一次性任务,需要结构化自动化时则必须选择现有的协议接口。 +这是有意为之的破坏性变更。`dsh-cli-demo`、它的 `--output-format` 选项以及对 `@deepseek-ai/dsh-cli-demo/src/cli.ts` 的导入都不再可解析。本变更不提供公开的事件流替代接口;调用方使用 `dsh --profile headless` 执行一次性任务,需要结构化自动化时则必须选择现有的协议接口。 -仓库通过仅供测试的基础设施保留后端回放覆盖,产品冒烟测试和 built-bin 验收则运行 `dsh run`。只有当独立的一次性包负责一套真正独立、带版本且不能归产品启动器所有的协议时,它才可以重新引入;第二种命令写法或输出 shim 并不足以构成理由。 +仓库通过仅供测试的基础设施保留后端回放覆盖,产品冒烟测试和 built-bin 验收则运行 `dsh --profile headless`。只有当独立的一次性包负责一套真正独立、带版本且不能归产品启动器所有的协议时,它才可以重新引入;第二种命令写法或输出 shim 并不足以构成理由。 ## 验证 -聚焦的 Loader 冒烟测试在源码模式和由普通 Node 启动的构建模式下覆盖显式组装,快照测试对比其规范 JSONL 和持久化日志,产品验收覆盖 `dsh run`,文档检查及生成图谱/目录门禁则拒绝对已移除包的活跃引用。冻结的 Agent Note 归档保留为历史证据,不会被重写。 +聚焦的 Loader 冒烟测试在源码模式和由普通 Node 启动的构建模式下覆盖显式组装,快照测试对比其规范 JSONL 和持久化日志,产品验收覆盖 `dsh --profile headless`,文档检查及生成图谱/目录门禁则拒绝对已移除包的活跃引用。冻结的 Agent Note 归档保留为历史证据,不会被重写。 diff --git a/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.i18n.yaml new file mode 100644 index 0000000000..5317c7911e --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md +2026-08-09-remove-repository-plugin.md: 8ac6fd18b756e227f8dabc82eb4926a51702c5a1 +2026-08-09-remove-repository-plugin.zh.md: 6e504a151a88b87b8093a91a91ede1cb919a0b45 diff --git a/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md new file mode 100644 index 0000000000..8ac6fd18b7 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.md @@ -0,0 +1,44 @@ +# Agent Note: Remove the dedicated repository Plugin path + +Status: implemented + +English | [中文](2026-08-09-remove-repository-plugin.zh.md) + +## Problem + +The repository Plugin path duplicated the profile bundle path for installing and composing third-party packages. It added a `.dsh-plugin` manifest, a generated wrapper, a preparation executable, a second Git/package cache, a Loader builtin, and repository-specific Skill and MCP adapters. Profile bundles already install npm or Git package specifications through the profile package manager, retain normal dependency and lifecycle semantics, and contribute an ordered `cordis.patch.yml` layer that can mount ordinary Cordis Plugins. + +The duplicate path also exposed less configuration than a bundle. Its `repositories` list selected source strings, but the generated wrapper mounted a code entry without a user-supplied Plugin config. Repository-specific preparation therefore added substantial code and CI work without becoming the general external-Plugin distribution mechanism. + +## Decision + +DeepSeek Harness has one standalone external-Plugin distribution path: installable profile bundles. `dsh plugin --profile add ` records the dependency in the profile package, and the installed package declares `dsh.bundle.patch` to contribute its patch layer. The package manager owns source acquisition, versions, dependencies, build lifecycles, and its lockfile. The bundle patch owns Cordis Plugin selection and complete Plugin config. + +The `@deepseek-ai/dsh-repository-plugin` package, `.dsh-plugin` authoring format, `dsh-plugin-prepare` executable, generated wrapper, immutable repository cache, base `repository-plugins` row, and dedicated GitHub acceptance lane are removed. The unused vendored `@cordisjs/plugin-loader/repository` subpath and its bundled pnpm dependency are removed with their only consumer. Existing repository cache directories are inert user data; DSH neither reads nor deletes them. + +Bundles compose existing owners directly. A bundle that contributes Skills mounts `@deepseek-ai/dsh-skill-local`; one that contributes MCP servers mounts `@deepseek-ai/dsh-mcp-client`; native behavior mounts an ordinary compiled Cordis Plugin. These packages retain their own validation, lifecycle, registration, and teardown contracts. No compatibility parser or migration from `.dsh-plugin` is retained under the pre-release compatibility policy. + +This note consolidates the removed repository cache, static format, config-only integration, npm-backed preparation, and trusted code-entry decisions. Their original motivation survives here: standalone users need package-manager-owned external composition, Git and npm dependencies may execute trusted lifecycle code, static Skill and MCP contributions should reuse their existing owners, and source identity belongs in the profile dependency specification and lockfile. Their implementation-specific wrappers, cache generations, and preparation protocol no longer constrain the product. + +## Alternatives considered + +**Keep repository Plugin as a convenience wrapper over bundles.** Rejected because it would preserve two install commands, two manifest formats, and two failure/cache identities for the same package. A convenience that cannot pass ordinary Plugin config also remains less capable than the mechanism it wraps. + +**Teach the repository wrapper to load a bundle patch.** Rejected because the repository cache and preparation protocol would still duplicate profile dependency installation. Bundle packages are already accepted from npm, Git, file, and link specifications through pnpm. + +**Keep the generic Loader repository cache for possible future consumers.** Rejected because it has no current consumer after the package removal and carries a pinned package-manager runtime in a vendored browser-adjacent package. A dedicated cache is warranted again only if configuration-time activation without an explicit installation becomes a product requirement that profile dependencies cannot satisfy; that consumer can choose its cache contract then. + +**Disable repository Plugin but retain its on-disk format for migration.** Rejected under the pre-release stance. Retaining a parser or compatibility loader would keep the removed contract alive without an external compatibility obligation. + +## Consequences + +- Third-party packages use one installation and composition model, with ordinary dependency declarations and full patch-level Plugin config. +- Installing or updating an external bundle is an explicit `dsh plugin` package-manager operation rather than a watched source-list edit. User patch HMR still configures rows contributed by installed bundles. +- Profile installation requires `pnpm` on the host `PATH`. This is acceptable for an explicit package-management operation and avoids shipping the removed cache's pinned package-manager runtime solely for configuration-time activation. +- `.dsh-plugin` packages and existing repository source-list patches stop working. Their cache files remain removable by the user but are not migrated or automatically deleted. +- The dedicated pnpm runtime, preparation executable, wrapper generator, Git credential CI setup, repository cache, and repository-specific tests disappear. +- Package-relative static assets need a bundle-owned path form so a declarative bundle can point `dsh-skill-local`, `dsh-mcp-client`, or another Plugin at files it ships without custom runtime glue. That capability is owned by the bundle format rather than a repository adapter. + +## Testing + +Static gates reject stale package, config, documentation, graph, and workspace references. The existing `dsh plugin` built-CLI acceptance covers profile initialization, package-manager installation, bundle discovery, and layer reconciliation. Declarative package-relative Skill and MCP bundle resources remain a named coverage gap in this removal layer. diff --git a/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.zh.md b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.zh.md new file mode 100644 index 0000000000..6e504a151a --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-09-remove-repository-plugin.zh.md @@ -0,0 +1,44 @@ +# Agent Note: 移除专用 repository 插件路径 + +Status: implemented + +[English](2026-08-09-remove-repository-plugin.md) | 中文 + +## 问题 + +repository 插件路径与 profile 组合包路径重复实现了第三方包的安装和组合。它增加了 `.dsh-plugin` manifest(元数据清单)、生成的包装层、准备工作可执行文件、第二套 Git/包缓存、Loader 内置项,以及 repository 专用的 skill(技能)和 MCP 适配器。profile 组合包已经能通过 profile 包管理器安装 npm 或 Git 包说明符,保留正常的依赖与生命周期语义,并提供一个有序 `cordis.patch.yml` 层,其中可以挂载普通 Cordis 插件。 + +重复的路径所能提供的配置也少于组合包。其 `repositories` 列表选择源字符串,但生成的包装层挂载代码入口时无法传入用户提供的插件配置。因此,repository 专用的准备流程增加了大量代码和 CI 工作,却没有成为通用的外部插件分发机制。 + +## 决策 + +DeepSeek Harness 只保留一种独立的外部插件分发路径:可安装的 profile 组合包。`dsh plugin --profile add ` 将依赖记录到 profile 包中,安装的包通过声明 `dsh.bundle.patch` 提供自己的 patch 层。包管理器负责获取源、管理版本和依赖、运行构建生命周期,并维护锁文件。组合包 patch 负责选择 Cordis 插件并提供完整的插件配置。 + +移除 `@deepseek-ai/dsh-repository-plugin` 包、`.dsh-plugin` 编写格式、`dsh-plugin-prepare` 可执行文件、生成的包装层、不可变 repository 缓存、base 中的 `repository-plugins` 配置项,以及专用 GitHub 验收流水线。vendor 中未再使用的 `@cordisjs/plugin-loader/repository` 子路径及其随附的 pnpm 依赖,也随唯一消费方一并移除。现有 repository 缓存目录只是不会再产生作用的用户数据;DSH 既不会读取,也不会删除这些目录。 + +组合包直接组合现有归属方。提供 skill 的组合包挂载 `@deepseek-ai/dsh-skill-local`;提供 MCP 服务器的组合包挂载 `@deepseek-ai/dsh-mcp-client`;原生行为则挂载普通的已编译 Cordis 插件。这些包继续保有各自的校验、生命周期、注册和 teardown 契约。根据预发布兼容政策,不保留针对 `.dsh-plugin` 的兼容解析器或迁移机制。 + +本说明整合了已移除的 repository 缓存、静态格式、纯配置集成、由 npm 支持的准备流程和受信任代码入口决策。其原始动机保留于此:独立用户需要由包管理器负责的外部组合方式;Git 和 npm 依赖可以执行受信任的生命周期代码;静态 skill 与 MCP 贡献应复用现有归属方;来源标识应位于 profile 的依赖说明符和锁文件中。相应实现特有的包装层、缓存 generation 和准备协议不再约束产品。 + +## 曾考虑的替代方案 + +**保留 repository 插件,将其作为组合包的便利包装层。** 不予采纳,因为这会为同一个包保留两条安装命令、两种 manifest 格式,以及两套失败/缓存标识。如果一层便利包装不能传递普通的插件配置,其能力仍然不及它所包装的机制。 + +**让 repository 包装层加载组合包 patch。** 不予采纳,因为 repository 缓存和准备协议仍会重复 profile 依赖安装。组合包已经可以通过 pnpm 接受 npm、Git、file 和 link 说明符。 + +**为未来可能出现的消费方保留通用 Loader repository 缓存。** 不予采纳,因为在移除相关包后,它已无当前消费方,却仍让一个 vendor 中与浏览器相邻的包携带固定版本的包管理器运行时。只有当无需显式安装即可在配置阶段激活这一能力成为 profile 依赖无法满足的产品需求时,才有理由重新引入专用缓存;届时该消费方可以选择自己的缓存约定。 + +**禁用 repository 插件,但保留其磁盘格式以供迁移。** 根据预发布方针,不予采纳。保留解析器或兼容 loader 会在没有外部兼容义务的情况下,让已移除的契约继续存在。 + +## 后果 + +- 第三方包统一使用一种安装与组合模型,采用普通依赖声明和完整的 patch 层插件配置。 +- 安装或更新外部组合包时,必须显式通过 `dsh plugin` 执行包管理器操作,而不是编辑受监听的源列表。用户 patch 的 HMR(热模块替换)仍可配置已安装组合包所提供的配置项。 +- 安装 profile 时,宿主机的 `PATH` 中必须提供 `pnpm`。对于显式的包管理操作,这一要求可以接受,并且可避免仅为配置阶段激活而随产品交付已移除缓存所使用的固定版本包管理器运行时。 +- `.dsh-plugin` 包和现有 repository 源列表 patch 停止工作。用户仍可自行删除其缓存文件,但系统不会迁移或自动删除这些文件。 +- 专用 pnpm 运行时、准备工作可执行文件、包装层生成器、Git 凭据 CI 设置、repository 缓存和 repository 专用测试全部消失。 +- 静态资源需要一种由组合包拥有、可相对于包解析的路径形式,使声明式组合包可以将 `dsh-skill-local`、`dsh-mcp-client` 或其他插件指向它随包交付的文件,而无需定制运行时代码。该能力归组合包格式所有,而不是 repository 适配器。 + +## 测试 + +静态门禁会拒绝残留的包、配置、文档、图和 workspace 引用。现有 `dsh plugin` 已构建 CLI(命令行界面)验收测试覆盖 profile 初始化、包管理器安装、组合包发现和层调和。声明式、相对于包解析的 skill 与 MCP 组合包资源仍是本移除层中已明确记录的覆盖缺口。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml new file mode 100644 index 0000000000..e67d7df745 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md +2026-08-10-default-presets-single-editor.md: 82f254079080aeb88d76f4e7cc2c7ab195646ec4 +2026-08-10-default-presets-single-editor.zh.md: 22bbc19feadfb71fe3a9480bc60b8777e1fb50c2 diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md new file mode 100644 index 0000000000..82f2540790 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.md @@ -0,0 +1,25 @@ +# Agent Note: One editor family in general-purpose presets + +Status: implemented + +English | [中文](2026-08-10-default-presets-single-editor.zh.md) + +## Problem + +The `standard`, `code`, and `cordis` presets exposed both the `read`/`write`/`edit` filesystem tools and `str_replace_editor`. The two interfaces overlap for ordinary file inspection and editing, so every request carried an additional tool schema without adding a distinct default capability. The `minimal` preset has a different composition contract: its exact two-tool roster intentionally includes `str_replace_editor` beside persistent `bash`. + +## Decision + +The `standard`, `code`, and `cordis` preset configurations mount `dsh-tool-fs` and `dsh-tool-fs-search`, but do not mount `dsh-tool-str-replace-editor`. Code Mode therefore omits `str_replace_editor` from both its registry and generated SDK. The `minimal` preset continues to mount `dsh-tool-str-replace-editor`, and deployments or user-authored presets may still mount the plugin explicitly. + +This decision narrows the preset roster rather than removing the tool package or its Python runtime support. The earlier [shared-roster decision](../feature/2026-07-31-even-out-shipped-tool-rosters.md) continues to own why surface-neutral tools live in preset composition; this note owns the editor exception. + +## Alternatives considered + +**Keep both editing interfaces in the general-purpose presets.** Rejected because the overlapping model-visible schemas increase tool choice without supplying a separate default operation. + +**Remove `str_replace_editor` from every shipped composition.** Rejected because the `minimal` preset intentionally exposes that schema as one of its two tools, and explicit deployments remain valid consumers of the standalone plugin. + +## Consequences + +General-purpose agents use `read`, `write`, and `edit` for filesystem mutations, while the minimal agent retains `str_replace_editor`. Preset composition tests pin its absence from the standard roster, the Cordis roster, and the Code Mode SDK, while the minimal assertions continue to pin its presence. diff --git a/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md new file mode 100644 index 0000000000..22bbc19fea --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-default-presets-single-editor.zh.md @@ -0,0 +1,25 @@ +# Agent Note: 通用 preset 只提供一套编辑工具 + +Status: implemented + +[English](2026-08-10-default-presets-single-editor.md) | 中文 + +## 问题 + +`standard`、`code` 和 `cordis` preset 同时提供 `read`/`write`/`edit` 文件系统工具与 `str_replace_editor`。两套接口在常规文件查看和编辑上重叠,导致每次请求都携带额外的工具 schema,却没有增加独立的默认能力。`minimal` preset 具有不同的组合约定:它固定的双工具清单有意在持久 `bash` 之外提供 `str_replace_editor`。 + +## 决策 + +`standard`、`code` 和 `cordis` preset 配置挂载 `dsh-tool-fs` 与 `dsh-tool-fs-search`,但不挂载 `dsh-tool-str-replace-editor`。因此 Code Mode 的注册表和生成的 SDK 均不包含 `str_replace_editor`。`minimal` preset 继续挂载 `dsh-tool-str-replace-editor`,部署配置或用户自定义 preset 仍可显式挂载该插件。 + +此决策收窄 preset 工具清单,不移除工具包及其 Python 运行时支持。较早的[共享清单决策](../feature/2026-07-31-even-out-shipped-tool-rosters.md)继续说明与 surface 无关的工具为何归 preset 组合所有;本记录说明编辑器例外。 + +## 曾考虑的替代方案 + +**在通用 preset 中保留两套编辑接口。** 不予采用,因为重叠的模型可见 schema 增加了工具选择,却没有提供不同的默认操作。 + +**从所有交付组合中移除 `str_replace_editor`。** 不予采用,因为 `minimal` preset 有意将该 schema 作为两个工具之一,显式部署仍是该独立插件的有效消费方。 + +## 后果 + +通用 agent 使用 `read`、`write` 和 `edit` 完成文件系统修改,minimal agent 保留 `str_replace_editor`。preset 组合测试固定其不会出现在 standard 清单、Cordis 清单及 Code Mode SDK 中,同时 minimal 断言继续固定其存在。 diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml new file mode 100644 index 0000000000..9920c09c11 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.i18n.yaml @@ -0,0 +1,6 @@ +# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each +# side as of the last confirmed-consistent state. Both languages carry equal authority; +# after editing either side, bring the other along and re-record with: +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md +2026-08-10-source-run-without-managed-installer.md: ac506b72acab0dd6c92ce6111487d091b2bf4a73 +2026-08-10-source-run-without-managed-installer.zh.md: 86e44a90a9e7b34ad37b6a4bfd3ad14de40868f5 diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md new file mode 100644 index 0000000000..ac506b72ac --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.md @@ -0,0 +1,31 @@ +# Agent Note: Source run without a managed installer + +Status: implemented + +English | [中文](2026-08-10-source-run-without-managed-installer.zh.md) + +## Problem + +A repository-owned source installer can provide a stable launcher, isolated staging worktrees, atomic upgrades, rollback storage, and shared maintenance workflows for personal customizations. It also makes the repository responsible for a second lifecycle beside the package manager: host dependency installation, credential prompting, checkout adoption, symlink ownership, staging branch coordination, upgrade recovery, and continued compatibility between the installer and bundled maintenance skills. + +That lifecycle is not required to run or develop DeepSeek Harness from a source checkout. Maintaining it expands the supported filesystem and Git state space without improving the repository-native execution path. + +## Decision + +The repository supports source execution through its root `pnpm` scripts. The `dsh` entry in `package.json` runs `pnpm run build`, then launches `apps/cli/src/bin.ts` through `node --import tsx/esm`; build output remains visible before the CLI output. The package script forwards arguments and inherits the caller's environment, including `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. Users select Web with `pnpm dsh web` and headless execution with `pnpm dsh --profile headless "task"`. The independent ACP example remains available through `pnpm run demo:acp`. + +The repository does not distribute a source installer, an installer test suite, or skills that assume a managed `current` symlink and timestamped staging worktrees. Users own source checkout placement, Git updates, and any launcher they create outside the repository. + +## Alternatives considered + +**Keep the installer but document `pnpm run` as another path.** This retains the managed launcher and rollback capability but keeps both lifecycle contracts active, including the installer tests and staging-aware skills. + +**Keep generic customization and upstream-publication skills.** Their safety rules can apply beyond the staging layout, but the shipped workflows form one coupled maintenance system: customization discovers the installed staging checkout, upgrade performs the cutover, and upstream publication is selected from those personal changes. General Git contribution guidance already belongs to repository instructions and does not require product-bundled skills. + +**Replace the installer with a smaller launcher-link script.** This reduces setup behavior but still makes the repository responsible for host PATH mutation and launcher ownership. Source scripts provide the entry points without that state. + +## Consequences + +Source users invoke repository scripts rather than an installed `dsh` command. The repository provides no atomic upgrade cutover or preserved staging rollback checkout, and it does not automate the integration or upstream publication of personal source modifications. A future distribution mechanism must justify its ownership of installation and upgrade state, define recovery behavior, and add tests and user documentation without making the source-run path depend on it. Any future publication workflow must isolate one approved feature and obtain explicit approval before its first push and draft PR. + +Verification covers repository-wide references to the removed entry points, documentation links, generated third-party-notice freshness, the build-first `package.json` command, and a source CLI smoke through the exact `node --import tsx/esm` runtime vector. diff --git a/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md new file mode 100644 index 0000000000..86e44a90a9 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-10-source-run-without-managed-installer.zh.md @@ -0,0 +1,31 @@ +# Agent Note: 无需托管安装器的源码运行 + +Status: implemented + +[English](2026-08-10-source-run-without-managed-installer.md) | 中文 + +## 问题 + +仓库自带的源码安装器可以提供稳定的启动器、相互隔离的 staging worktree、原子升级、回滚存储,以及用于个人定制的共享维护工作流。与此同时,仓库还必须在包管理器之外负责第二套生命周期:安装宿主依赖、提示输入凭证、接管检出、管理符号链接归属、协调 staging 分支、处理升级恢复,以及持续保持安装器与随附维护 skill(技能)的兼容性。 + +从源码检出运行或开发 DeepSeek Harness 并不需要这套生命周期。维护它会扩大需要支持的文件系统和 Git 状态空间,却无法改进仓库原生的执行路径。 + +## 决策 + +仓库通过根目录的 `pnpm` 脚本支持从源码运行。`package.json` 中的 `dsh` 项先执行 `pnpm run build`,再通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`;构建输出会显示在 CLI(命令行界面)输出之前。该包脚本会转发参数并继承调用方环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,调用方可设置 `NODE_USE_ENV_PROXY=1`。用户使用 `pnpm dsh web` 选择 Web,使用 `pnpm dsh --profile headless "task"` 选择无头执行。独立的 ACP(Agent Client Protocol)示例仍可通过 `pnpm run demo:acp` 运行。 + +仓库不分发源码安装器、安装器测试套件,也不分发依赖受管理的 `current` 符号链接和带时间戳 staging worktree 的 skill。源码检出的存放位置、Git 更新,以及用户在仓库外创建的任何启动器均由用户负责。 + +## 考虑过的备选方案 + +**保留安装器,但将 `pnpm run` 记作另一条路径。**这样可以保留受管理的启动器和回滚能力,但两套生命周期约定仍会同时生效,其中包括安装器测试和依赖 staging 布局的 skill。 + +**保留通用的定制与上游发布 skill。**其中的安全规则也能用于 staging 布局之外,但现有工作流共同构成了一套耦合的维护系统:定制工作流查找已安装的 staging 检出,升级工作流执行切换,上游发布工作流则从这些个人修改中选择发布内容。通用 Git 贡献指南已经属于仓库指令,无需以产品随附 skill 的形式提供。 + +**用更小的启动器链接脚本替换安装器。**这样可以简化设置过程,但仓库仍需负责修改宿主 PATH 和管理启动器归属。源码脚本无需引入这类状态即可提供入口点。 + +## 影响 + +源码用户通过仓库脚本运行程序,而非使用已安装的 `dsh` 命令。仓库不提供原子升级切换,也不保留 staging 回滚检出;仓库同样不会自动集成个人源码修改或将其发布到上游。未来的分发机制必须说明为何应由其管理安装和升级状态,定义恢复行为,并补充测试与用户文档,同时不得让源码运行路径依赖该机制。未来任何发布工作流都必须隔离出一项获批功能,并在首次推送和创建草稿 PR(Pull Request)前取得明确批准。 + +验证范围包括仓库内对已移除入口点的所有引用、文档链接、生成的第三方声明文件的新鲜度、`package.json` 中的先构建后启动命令,以及通过准确的 `node --import tsx/esm` 运行方式对源码 CLI 进行的冒烟测试。 diff --git a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml similarity index 52% rename from .agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml rename to .agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.i18n.yaml index df68353802..945a68054c 100644 --- a/.agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.i18n.yaml +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.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 .agents/notes/implemented/architecture/2026-07-30-package-manager-native-repository-cache.md -2026-07-30-package-manager-native-repository-cache.md: 38e7356d4abfc8eba8854f0a96700da448ff4ac4 -2026-07-30-package-manager-native-repository-cache.zh.md: 6833eeb4279c9feb9ac1860e780b8ed58b09d334 +# pnpm run verify-translation-pairing --write .agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md +2026-08-11-remove-empty-experimental-package-group.md: e5e81e3e3763f216921b3f3b74709b64be3dee37 +2026-08-11-remove-empty-experimental-package-group.zh.md: d44d0daaf346a5fea317f8c8c6a23f26eaa3cec0 diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md new file mode 100644 index 0000000000..e5e81e3e37 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.md @@ -0,0 +1,37 @@ +# Agent Note: Remove the empty experimental package group + +Status: implemented + +English | [中文](2026-08-11-remove-empty-experimental-package-group.zh.md) + +## Problem + +The package hierarchy reserves `packages/experimental/` for prototypes and internal-only plugins, but no package has used the group. The empty group adds placement, dependency, promotion, and release rules without a current package or release mechanism that needs them. + +The original group aimed to let the team share prototypes against the real plugin graph without implying product support. That need remains possible, but it does not justify a permanent repository category before a concrete package exists. + +## Decision + +The package hierarchy has no reserved experimental or internal-only group. Packages continue to live in groups selected for their current product role. + +A concrete package that needs different release, stability, or dependency treatment requires a decision based on its actual consumers and release mechanism. That decision may reintroduce a dedicated group when it can also define and enforce the exclusion rules. + +This note consolidates and supersedes the experimental-package-group decision, whose active triplet is removed with the empty directory. + +## Alternatives considered + +**Keep the empty group.** It provides an obvious future incubation location, but it also keeps repository rules with no current owner, package, or enforcement mechanism. + +**Move the experimental rules into the general package instructions.** This preserves the policy without an empty directory, but makes every package change carry rules for a hypothetical package class. + +**Put concrete experimental packages in product-role groups with README labels.** This preserves product-role colocation, but labels alone cannot enforce release and runtime-dependency rules. A future package can evaluate this option against its actual release mechanism. + +**Treat every package as experimental until the first tagged release.** This applies a broad temporary status without providing durable treatment for packages that remain experimental after releases begin. + +**Require prototypes to stay outside the repository.** This would lose access to the real plugin graph, examples, snapshots, and lifecycle checks. Removing the reserved group does not impose that restriction; a concrete prototype can establish the placement it needs. + +## Consequences + +The hierarchy loses an unused group and its special release and dependency policy. It also gives up a predeclared location for team discovery and a ready-made promotion path. + +The first package that needs experimental or internal-only treatment must define where it lives, how releases exclude it, which runtime dependencies are allowed, and what condition promotes or removes it. A dedicated group can return when those rules have a current consumer and enforceable mechanism. diff --git a/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md new file mode 100644 index 0000000000..d44d0daaf3 --- /dev/null +++ b/.agents/notes/implemented/simplification/2026-08-11-remove-empty-experimental-package-group.zh.md @@ -0,0 +1,37 @@ +# Agent Note: 移除空的实验性包分组 + +Status: implemented + +[English](2026-08-11-remove-empty-experimental-package-group.md) | 中文 + +## 问题 + +包层级结构预留 `packages/experimental/` 用于原型和内部专用插件,但从未有包使用该分组。这个空分组添加了放置、依赖、提升和发布规则,却没有需要这些规则的现有包或发布机制。 + +原分组旨在让团队基于真实插件图共享原型,同时不暗示产品会提供支持。这项需求将来可能出现,但在具体包出现前,不足以支持一个永久的仓库类别。 + +## 决策 + +包层级结构不再预留实验性或内部专用分组。包继续按照当前产品职责放入对应分组。 + +如果具体包需要不同的发布、稳定性或依赖处理,必须根据其实际消费方和发布机制做出决策。只要该决策同时定义并强制执行排除规则,就可以重新引入专用分组。 + +本 Agent Note 整合并取代实验性包分组决策;该旧决策的活跃三文件组随空目录一并移除。 + +## 考虑过的替代方案 + +**保留空分组。** 它为未来孵化工作提供明确位置,但也会保留没有当前负责人、包或强制执行机制的仓库规则。 + +**将实验性规则移入通用包指令。** 这可以在不保留空目录的情况下延续政策,但会让每次包变更都携带针对假设包类别的规则。 + +**将具体实验性包放入产品职责分组,并用 README 标注。** 这会保持产品职责共置,但仅靠标注无法强制执行发布和运行时依赖规则。未来的包可以根据实际发布机制评估此选项。 + +**在首个带标签的版本发布前,将每个包都视为实验性。** 这会施加宽泛的临时状态,却无法为发布开始后仍处于实验状态的包提供持久处理方式。 + +**要求原型留在仓库外。** 这会失去真实插件图、示例、快照和生命周期检查。移除预留分组并不施加这项限制;具体原型可以建立自身所需的放置规则。 + +## 后果 + +包层级结构移除了未使用的分组及其特殊发布和依赖政策,同时也放弃了预先声明的团队发现位置和现成的提升路径。 + +第一个需要实验性或内部专用处理的包必须定义其存放位置、发布版本如何排除它、允许哪些运行时依赖,以及包在何种条件下获得提升或被移除。当这些规则具有当前消费方和可强制执行的机制时,可以恢复专用分组。 diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml index cc081c72c4..7f86295344 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md -2026-07-24-web-gui-browser-e2e-lane.md: e572929ae6762da6adc2e77e1dba19361beaf670 -2026-07-24-web-gui-browser-e2e-lane.zh.md: 99f86f40ba006c4024f367b73ce52f8679b8d2fd +2026-07-24-web-gui-browser-e2e-lane.md: 6e52d96a8adb5486e8666d65a3425bf5a0aad4a9 +2026-07-24-web-gui-browser-e2e-lane.zh.md: 7598a8edc530a34261799e57ce953edafe44e70e diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md index e572929ae6..6e52d96a8a 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md @@ -88,7 +88,7 @@ Surveyed AI-chat/agent web UIs and mocking layers (LibreChat, vercel/ai-chatbot - **Follow-up-prompt-after-resume scenario**: the history/live stitch path over the real wire; add as its own scenario when that code changes or regresses. - **Composer steering gesture**: the input locks while running (stop-or-wait), so the steering scenario steers over the wire from the page; `TODO(web-steer-composer)` upgrades the drive step to a real composer gesture when the product grows one. - **Drag session reorder**: `workspace.insertSessionBefore` has no browser scenario; it needs two sessions materialized in one workspace plus synthesized HTML5 drag events. Add it when that surface changes or regresses. The inert session Rename/Fork/Delete and workspace Delete menu rows get scenarios when they gain behavior. -- **Long-history Chat-to-Trajectory Inspect**: the independent inspection source exhausts history after the view opens, while the selected record is addressed by a derived table index that can move as older pages prepend. Short-history Inspect remains covered; the long-history interaction contract excludes this handoff until selection has a stable semantic identity. +- **Long-history Chat-to-Trajectory Inspect**: both views share Session paging, while the selected Trajectory record is addressed by a derived table index that can move as older pages prepend. Short-history Inspect remains covered; the long-history interaction contract excludes this handoff until selection has a stable semantic identity. ## Consequences diff --git a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md index 99f86f40ba..7598a8edc5 100644 --- a/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md +++ b/.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.zh.md @@ -88,7 +88,7 @@ Web GUI 以一条真实组装链交付——chromium 页面 → client 插件 bu - **恢复后追问场景**:真实 wire 上的历史/实时缝合路径;当该代码变更或回归时作为独立场景补充。 - **输入框 steering 手势**:输入在运行期间锁定(只能停止或等待),因此 steering 场景从页面走 wire 做 steer;`TODO(web-steer-composer)` 待产品长出真实的输入框手势后,把驱动步骤升级为该手势。 - **拖拽会话重排**:`workspace.insertSessionBefore` 尚无浏览器场景;它需要在同一个工作区里物化两个会话,并合成 HTML5 拖拽事件。当该表面变更或回归时再补充。无行为的会话 Rename/Fork/Delete 和工作区 Delete 菜单行待获得行为后再补充场景。 -- **长历史 Chat 到 Trajectory 的 Inspect**:独立的检查数据源会在视图打开后穷尽历史,而所选记录由一个派生的表格索引定位;随着较早页面前插,该索引可能移动。短历史 Inspect 仍有覆盖;在选中项具有稳定的语义身份之前,长历史交互约定不包含这项交接。 +- **长历史 Chat 到 Trajectory 的 Inspect**:两个视图共用 Session 分页,而所选 Trajectory 记录由一个派生的表格索引定位;随着较早页面前插,该索引可能移动。短历史 Inspect 仍有覆盖;在选中项具有稳定的语义身份之前,长历史交互约定不包含这项交接。 ## 后果 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 a0a0dc998d..7084931b1d 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write .agents/notes/proposed/feature/2026-07-17-sdk-follow-up-capabilities.md -2026-07-17-sdk-follow-up-capabilities.md: 9a17f07139014f95666789e41cacb7af180ef5d8 -2026-07-17-sdk-follow-up-capabilities.zh.md: 2431c5c3d9e45605223d9bb1843e48a3c711759e +2026-07-17-sdk-follow-up-capabilities.md: f14d46a61f5fd3e64067441c2f8340cf94746a79 +2026-07-17-sdk-follow-up-capabilities.zh.md: 2e5efba340d503d2445e408bfc43ee0d6c6bec3c 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 9a17f07139..f14d46a61f 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,7 +47,7 @@ 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). +This proposal concerns dependencies of developer-owned SDK projects. Standalone apps install external packages as [profile bundles](../../implemented/simplification/2026-08-09-remove-repository-plugin.md), with their profile package manager and lockfile owning acquisition and lifecycle policy. ## Launcher telemetry 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 2431c5c3d9..2e5efba340 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,7 +47,7 @@ Create 和 config 使用相同的功能计划形状。create 通过上述命令 包管理器负责来源解析、版本或 commit 解析、`integrity` 数据、lockfile 更新和构建策略。SDK 不再通过 giget 或 pacote 下载、解压第二份副本。外部插件是 `node_modules` 下的依赖;本地插件脚手架仍属于独立的工程创建问题。 -本提案只涉及开发者自有 SDK 工程的依赖。独立应用的仓库缓存、随应用捆绑 pnpm 的政策和显式的准备流程信任边界,均由[包管理器原生仓库缓存](../../implemented/architecture/2026-07-30-package-manager-native-repository-cache.md)负责。 +本提案只涉及开发者自有 SDK 工程的依赖。独立应用将外部包安装为 [profile 组合包](../../implemented/simplification/2026-08-09-remove-repository-plugin.md),由 profile 的包管理器与 lockfile 负责获取和生命周期策略。 ## Launcher 遥测 diff --git a/.github/AGENTS.md b/.github/AGENTS.md index c18f5b5948..3e879db8ae 100644 --- a/.github/AGENTS.md +++ b/.github/AGENTS.md @@ -1,3 +1,3 @@ # AGENTS.md — GitHub Actions -Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is the deliberate exception: it runs Windows Node under Wine on hosted Linux and blocks `all checks passed`; `windows-native` runs automatically on `windows-2025` but reports independently — see the [dual-lane Agent Note](../.agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md). +Run jobs on Windows runners (`windows-*` labels) under native `pwsh`. The pull-request `windows` job is the deliberate exception: it runs Windows Node under Wine on hosted Linux and blocks `all checks passed`; `windows-native` runs automatically on `windows-2025` (or the self-hosted `[self-hosted, dsh-win-ci, windows]` pool under `DSH_CI_FAILOVER=selfhosted`) but reports independently. The master `serial-windows` standby continuously validates the self-hosted failover target — see the [failover runbook](../.agents/notes/implemented/process/2026-07-26-ci-failover-runbook.md). diff --git a/.github/issue-management/config.json b/.github/issue-management/config.json index 41019f0aa2..5dc925f245 100644 --- a/.github/issue-management/config.json +++ b/.github/issue-management/config.json @@ -3,6 +3,7 @@ "repository": "deepseek-harness", "projectNumber": 1, "projectTitle": "DSH Issue Management", + "lifecycleActor": "dsh-issue-management", "priorityField": "Priority", "allowUnassignedOwner": true, "statuses": [ diff --git a/.github/issue-management/policy.mjs b/.github/issue-management/policy.mjs index 2125ba2f36..24a82cf15f 100644 --- a/.github/issue-management/policy.mjs +++ b/.github/issue-management/policy.mjs @@ -37,10 +37,21 @@ const LEGACY_LABELS = new Set([ ]) const TERMINAL_STATUSES = new Set(['Done', 'No action']) const ACTIVE_STATUS_ORDER = config.statuses.filter((status) => !TERMINAL_STATUSES.has(status)) +const IMPLEMENTATION_PULL_REQUEST_ACTIONS = new Set([ + 'opened', + 'edited', + 'synchronize', + 'reopened', + 'labeled', + 'unlabeled', +]) for (const status of ['In progress', 'In review']) { if (!ACTIVE_STATUS_ORDER.includes(status)) throw new Error(`config.statuses 缺少 ${status}`) } +if (typeof config.lifecycleActor !== 'string' || !config.lifecycleActor) { + throw new Error('config.lifecycleActor 未设置') +} /** * Return Markdown outside balanced details elements. @@ -159,18 +170,48 @@ export function requiresPullRequestPolicy({ } /** - * Derive a forward-only Issue status from the current PR phase. - * @param {string|null} currentStatus Current Project status. - * @param {{isDraft: boolean, reviewRequestCount: number, reviewCount: number}} pull PR phase. - * @returns {string|null} Status to write, or null when no forward transition exists. + * Translate a repository event into one resolving-Issue lifecycle command. + * @param {string} eventName GitHub event name. + * @param {{action?: string, review?: {state?: string}}} event GitHub event payload. + * @returns {'implementation'|'review-requested'|'changes-requested'|null} Lifecycle command. */ -export function nextResolvingIssueStatus(currentStatus, pull) { - const target = - !pull.isDraft && (pull.reviewRequestCount > 0 || pull.reviewCount > 0) - ? 'In review' - : 'In progress' +export function resolvingIssueStatusCommand(eventName, event) { + if (eventName === 'pull_request') { + if (event.action === 'review_requested') return 'review-requested' + return IMPLEMENTATION_PULL_REQUEST_ACTIONS.has(event.action) ? 'implementation' : null + } + if ( + eventName === 'pull_request_review' && + event.action === 'submitted' && + event.review?.state?.toLowerCase() === 'changes_requested' + ) { + return 'changes-requested' + } + return null +} + +/** + * Plan one event-directed resolving-Issue status transition. + * @param {string|null} currentStatus Current Project status. + * @param {'implementation'|'review-requested'|'changes-requested'} command Lifecycle command. + * @param {string|null} currentStatusActor Actor that last set the current Project status. + * @returns {string|null} Status to write, or null when no permitted transition exists. + */ +export function nextResolvingIssueStatus(currentStatus, command, currentStatusActor = null) { + let target + if (command === 'review-requested') target = 'In review' + else if (command === 'implementation' || command === 'changes-requested') target = 'In progress' + else throw new Error(`未知 lifecycle command:${command}`) + const currentIndex = ACTIVE_STATUS_ORDER.indexOf(currentStatus) const targetIndex = ACTIVE_STATUS_ORDER.indexOf(target) + if ( + command === 'changes-requested' && + currentStatus === 'In review' && + currentStatusActor === config.lifecycleActor + ) { + return target + } return currentIndex >= 0 && currentIndex < targetIndex ? target : null } @@ -396,9 +437,15 @@ async function issueSnapshot(number, status = undefined) { } } -async function projectContext(number) { +async function projectContext(number, includeStatusActor = false) { const data = await graphql( - `query($organization: String!, $repository: String!, $number: Int!, $project: Int!) { + `query( + $organization: String! + $repository: String! + $number: Int! + $project: Int! + $includeStatusActor: Boolean! + ) { organization(login: $organization) { projectV2(number: $project) { id @@ -413,6 +460,16 @@ async function projectContext(number) { repository(owner: $organization, name: $repository) { issue(number: $number) { id + timelineItems(last: 100, itemTypes: [PROJECT_V2_ITEM_STATUS_CHANGED_EVENT]) + @include(if: $includeStatusActor) { + nodes { + ... on ProjectV2ItemStatusChangedEvent { + actor { login } + project { id } + status + } + } + } projectItems(first: 20, includeArchived: true) { nodes { id @@ -430,6 +487,7 @@ async function projectContext(number) { repository: config.repository, number, project: config.projectNumber, + includeStatusActor, }, ) const project = data.organization?.projectV2 @@ -439,7 +497,14 @@ async function projectContext(number) { const statusField = project.fields.nodes.find((field) => field?.name === 'Status') if (!statusField) throw new Error('Project 缺少 Status 字段') const item = issue.projectItems.nodes.find((candidate) => candidate.project.id === project.id) - return { project, issue, statusField, item } + const latestStatusEvent = issue.timelineItems?.nodes + ?.filter((event) => event?.project?.id === project.id) + .at(-1) + const statusActor = + latestStatusEvent?.status === item?.fieldValueByName?.name + ? (latestStatusEvent.actor?.login ?? null) + : null + return { project, issue, statusField, item, statusActor } } async function projectStatus(number) { @@ -530,12 +595,7 @@ async function auditIssue(number, extraErrors = [], status = undefined) { return errors } -async function pullRequestSnapshot(number) { - const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`) - const [reviewRequests, reviews] = await Promise.all([ - api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`), - api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`), - ]) +async function resolvingReferencesSnapshot(number, pull) { const references = parseReferences({ body: pull.body ?? '', repository: `${config.organization}/${config.repository}`, @@ -547,20 +607,41 @@ async function pullRequestSnapshot(number) { } return { number, - isDraft: pull.draft, - authorType: pull.user?.type ?? 'User', - reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, - reviewCount: reviews.length, - labels: pull.labels.map((label) => label.name), references: retainIssueReferences(references, issues), issues, } } -async function advanceResolvingIssues(pull) { +async function pullRequestSnapshot(number) { + const [pull, reviewRequests, reviews] = await Promise.all([ + api(`/repos/${config.organization}/${config.repository}/pulls/${number}`), + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/requested_reviewers`), + api(`/repos/${config.organization}/${config.repository}/pulls/${number}/reviews?per_page=100`), + ]) + const resolving = await resolvingReferencesSnapshot(number, pull) + return { + ...resolving, + isDraft: pull.draft, + authorType: pull.user?.type ?? 'User', + reviewRequestCount: reviewRequests.users.length + reviewRequests.teams.length, + reviewCount: reviews.length, + labels: pull.labels.map((label) => label.name), + } +} + +async function lifecyclePullRequestSnapshot(number) { + const pull = await api(`/repos/${config.organization}/${config.repository}/pulls/${number}`) + return resolvingReferencesSnapshot(number, pull) +} + +async function transitionResolvingIssues(pull, command) { for (const number of pull.references.resolving) { - const context = await projectContext(number) - const target = nextResolvingIssueStatus(context.item?.fieldValueByName?.name ?? null, pull) + const context = await projectContext(number, command === 'changes-requested') + const target = nextResolvingIssueStatus( + context.item?.fieldValueByName?.name ?? null, + command, + context.statusActor, + ) if (!target) continue // TODO: Replace this latest-state guard with per-Issue serialization or a // conditional ProjectV2 update; GraphQL currently has no compare-and-swap. @@ -598,8 +679,10 @@ async function runLifecycle(eventName, event) { } if (eventName === 'pull_request' || eventName === 'pull_request_review') { - const pull = await pullRequestSnapshot(event.pull_request.number) - await advanceResolvingIssues(pull) + const command = resolvingIssueStatusCommand(eventName, event) + if (!command) return + const pull = await lifecyclePullRequestSnapshot(event.pull_request.number) + await transitionResolvingIssues(pull, command) } } diff --git a/.github/issue-management/policy.test.mjs b/.github/issue-management/policy.test.mjs index 8a9b0f91e6..c03a7c3513 100644 --- a/.github/issue-management/policy.test.mjs +++ b/.github/issue-management/policy.test.mjs @@ -6,6 +6,7 @@ import { nextResolvingIssueStatus, parseReferences, retainIssueReferences, + resolvingIssueStatusCommand, requiresPullRequestPolicy, validateBody, validateIssue, @@ -243,32 +244,73 @@ test('requires policy only after a human PR enters review', () => { ) }) -test('advances resolving Issues to the live PR phase', () => { - const draft = { isDraft: true, reviewRequestCount: 1, reviewCount: 4 } - const open = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } - const requestedReview = { isDraft: false, reviewRequestCount: 1, reviewCount: 0 } - const submittedReview = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } - - for (const status of ['Inbox', 'Backlog', 'Ready']) { - assert.equal(nextResolvingIssueStatus(status, draft), 'In progress') - assert.equal(nextResolvingIssueStatus(status, open), 'In progress') - assert.equal(nextResolvingIssueStatus(status, requestedReview), 'In review') - assert.equal(nextResolvingIssueStatus(status, submittedReview), 'In review') +test('maps only explicit review handoffs to review status commands', () => { + assert.equal( + resolvingIssueStatusCommand('pull_request', { + action: 'review_requested', + }), + 'review-requested', + ) + assert.equal( + resolvingIssueStatusCommand('pull_request_review', { + action: 'submitted', + review: { state: 'changes_requested' }, + }), + 'changes-requested', + ) + for (const state of ['approved', 'commented']) { + assert.equal( + resolvingIssueStatusCommand('pull_request_review', { + action: 'submitted', + review: { state }, + }), + null, + ) } - assert.equal(nextResolvingIssueStatus('In progress', requestedReview), 'In review') - assert.equal(nextResolvingIssueStatus('In progress', submittedReview), 'In review') + assert.equal( + resolvingIssueStatusCommand('pull_request_review', { + action: 'dismissed', + review: { state: 'changes_requested' }, + }), + null, + ) }) -test('never regresses or reopens a resolving Issue', () => { - const implementation = { isDraft: false, reviewRequestCount: 0, reviewCount: 0 } - const review = { isDraft: false, reviewRequestCount: 0, reviewCount: 1 } +test('keeps ordinary pull request events as forward-only implementation signals', () => { + for (const action of ['opened', 'edited', 'synchronize', 'reopened', 'labeled', 'unlabeled']) { + assert.equal(resolvingIssueStatusCommand('pull_request', { action }), 'implementation') + } + assert.equal( + resolvingIssueStatusCommand('pull_request', { action: 'review_request_removed' }), + null, + ) +}) - assert.equal(nextResolvingIssueStatus('In progress', implementation), null) - assert.equal(nextResolvingIssueStatus('In review', implementation), null) - assert.equal(nextResolvingIssueStatus('In review', review), null) - assert.equal(nextResolvingIssueStatus('Done', review), null) - assert.equal(nextResolvingIssueStatus('No action', review), null) - assert.equal(nextResolvingIssueStatus(null, review), null) +test('toggles automation-owned work on request changes and repeated review request', () => { + for (const status of ['Inbox', 'Backlog', 'Ready']) { + assert.equal(nextResolvingIssueStatus(status, 'implementation'), 'In progress') + assert.equal(nextResolvingIssueStatus(status, 'review-requested'), 'In review') + assert.equal(nextResolvingIssueStatus(status, 'changes-requested'), 'In progress') + } + let status = nextResolvingIssueStatus( + 'In review', + 'changes-requested', + 'dsh-issue-management', + ) + assert.equal(status, 'In progress') + status = nextResolvingIssueStatus(status, 'review-requested') + assert.equal(status, 'In review') +}) + +test('preserves human review status and terminal Issues', () => { + assert.equal(nextResolvingIssueStatus('In progress', 'implementation'), null) + assert.equal(nextResolvingIssueStatus('In review', 'implementation'), null) + assert.equal(nextResolvingIssueStatus('In review', 'review-requested'), null) + assert.equal(nextResolvingIssueStatus('In review', 'changes-requested', 'tianyicui'), null) + assert.equal(nextResolvingIssueStatus('In review', 'changes-requested'), null) + assert.equal(nextResolvingIssueStatus('Done', 'review-requested'), null) + assert.equal(nextResolvingIssueStatus('No action', 'changes-requested'), null) + assert.equal(nextResolvingIssueStatus(null, 'review-requested'), null) }) test('keeps lifecycle projection independent of PR metadata enforcement', () => { @@ -283,7 +325,7 @@ test('keeps lifecycle projection independent of PR metadata enforcement', () => } assert.ok(validatePullRequest(pull).length > 0) - assert.equal(nextResolvingIssueStatus('Inbox', pull), 'In review') + assert.equal(nextResolvingIssueStatus('Inbox', 'review-requested'), 'In review') }) test('exempts Draft, Bot, and App PRs', () => { diff --git a/.github/workflows/build-exe-for-python-sdk.yml b/.github/workflows/build-exe-for-python-sdk.yml index 017b77ee75..0924da5b35 100644 --- a/.github/workflows/build-exe-for-python-sdk.yml +++ b/.github/workflows/build-exe-for-python-sdk.yml @@ -92,7 +92,7 @@ jobs: sdk-wheel: needs: plan - name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl runs-on: ubuntu-latest timeout-minutes: 5 steps: @@ -113,8 +113,8 @@ jobs: - uses: actions/upload-artifact@v7 with: - name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl - path: dist-python/deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl + path: dist-python/deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl if-no-files-found: error build: @@ -197,7 +197,7 @@ jobs: - uses: actions/download-artifact@v8 with: - name: deepseek_harness-${{ needs.plan.outputs.version }}-py3-none-any.whl + name: deepseek_harness_sdk-${{ needs.plan.outputs.version }}-py3-none-any.whl path: dist-python - name: Install only the SDK into a clean venv and run zero-config @@ -208,7 +208,7 @@ jobs: python -m venv "$RUNNER_TEMP/dsh-sdk-smoke" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" -m pip install \ --find-links dist-python \ - deepseek-harness=="$VERSION" + deepseek-harness-sdk=="$VERSION" "$RUNNER_TEMP/dsh-sdk-smoke/bin/python" scripts/smoke-python-runtime.py \ --scenario sdk-default @@ -238,7 +238,7 @@ jobs: esac docker run --rm -e VERSION -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c ' /opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk - /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness=="$VERSION" + /tmp/dsh-sdk/bin/python -m pip install --find-links /work/dist-python deepseek-harness-sdk=="$VERSION" /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default ' diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index eebdafaa48..1249439fde 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -175,8 +175,6 @@ jobs: DSH_NODE_COMPAT_SKIP_TYPECHECK: '1' DSH_OXLINT_THREADS: '8' DSH_PUBLINT_CONCURRENCY: '8' - DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE: >- - github:${{ github.event.pull_request.head.repo.full_name }}#${{ github.event.pull_request.head.sha }}&path:/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin # Failover halves snapshot concurrency for the shared 64-core VM. DSH_SNAPSHOT_MAX_CONCURRENCY: ${{ vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' && '12' || '32' }} steps: @@ -242,17 +240,6 @@ jobs: if: vars.DSH_CI_FAILOVER == 'selfhosted' && github.event.pull_request.user.login != 'dependabot[bot]' run: pnpm --filter @deepseek-ai/dsh-frontend exec playwright install chromium - - name: Configure private GitHub repository Plugin access - env: - DSH_GITHUB_SOURCE_TOKEN: ${{ github.token }} - run: | - source_config="$RUNNER_TEMP/dsh-github-source.gitconfig" - basic_auth=$(printf 'x-access-token:%s' "$DSH_GITHUB_SOURCE_TOKEN" | base64 | tr -d '\n') - git config --file "$source_config" url.https://github.com/.insteadOf git@github.com: - git config --file "$source_config" --add url.https://github.com/.insteadOf ssh://git@github.com/ - git config --file "$source_config" http.https://github.com/.extraheader "AUTHORIZATION: basic $basic_auth" - echo "GIT_CONFIG_GLOBAL=$source_config" >> "$GITHUB_ENV" - - name: Run compatibility, snapshot, and artifact gates run: pnpm run check:ci:consumers @@ -318,7 +305,7 @@ jobs: # Windows Node under Wine on standard hosted Linux. The independent # windows-native job below keeps the complete native-kernel inventory — # including the observational portability gates this lane does not run — - # on real windows-2025. This job only provisions runner state (caches, + # on real Windows. This job only provisions runner state (caches, # apt); scripts/wine-windows-gates.sh owns the gate logic and is the same # script the optional local gate `pnpm run check:windows-wine` runs. # Current topology and fidelity limits live in @@ -424,12 +411,18 @@ jobs: # Every pull request also gets a real Windows-kernel signal. This job keeps # its own unmasked conclusion but is deliberately absent from - # all-checks-passed.needs, so it never delays or changes that required verdict. - # See the dual Wine/native pull-request CI decision: - # .agents/notes/implemented/process/2026-08-08-native-windows-pull-request-ci.md + # all-checks-passed.needs, so it never delays or changes that required + # verdict. Under normal operation it runs on the hosted larger runner; under + # failover (DSH_CI_FAILOVER=selfhosted) it retargets onto the in-house + # self-hosted Windows pool. Dependabot PRs are excluded from the self-hosted + # pool and stay queued for the hosted runner — see the failover runbook. windows-native: if: github.event_name == 'pull_request' - runs-on: dsh-windows-2025-16core + runs-on: >- + ${{ vars.DSH_CI_FAILOVER == 'selfhosted' + && github.event.pull_request.user.login != 'dependabot[bot]' + && fromJSON('["self-hosted", "dsh-win-ci", "windows"]') + || 'dsh-windows-2025-16core' }} name: windows node 24 / native complete timeout-minutes: 60 env: @@ -455,8 +448,10 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} - # Extracting the many-file pnpm store cache is slower than a clean install, - # and saving it adds more latency after the gates. + # Extracting the many-file pnpm store cache is slower than a clean + # install on hosted Windows runners, and saving it adds latency after + # the gates. The self-hosted VM's persistent store makes caching + # redundant. - name: Install (immutable) shell: pwsh run: pnpm install --frozen-lockfile @@ -621,10 +616,22 @@ jobs: DSH_SNAPSHOT_MAX_CONCURRENCY: '1' run: pnpm run check:ci + # Hot-standby drill for the in-house self-hosted Windows pool: every master + # move re-runs the complete unsharded Windows gate inventory on the persistent + # VM, continuously proving that environment can take over the required + # `windows` lane if the hosted pool degrades (the switch is setting the + # writer-manageable DSH_CI_FAILOVER variable — see the failover runbook, no + # merge required). Push-triggered, so this lane always executes the base + # branch's own workflow definition. Non-blocking for pull requests; absent + # from all-checks-passed.needs by design — the required `windows` job owns + # the PR verdict. No cache steps because the VM's persistent pnpm store + # and tool caches make them redundant (and saving here would poison the + # hosted cache namespace with self-hosted paths). serial-windows: - if: false - name: serial / windows - runs-on: windows-2025 + if: github.event_name == 'push' && github.ref == 'refs/heads/master' + name: serial / windows (self-hosted standby) + runs-on: [self-hosted, dsh-win-ci, windows] + timeout-minutes: 60 steps: - uses: actions/checkout@v6 @@ -642,20 +649,23 @@ jobs: with: node-version: ${{ env.PRIMARY_NODE_VERSION }} + - name: Configure persistent pnpm store + shell: pwsh + run: | + $storeRoot = "$env:LOCALAPPDATA\pnpm\store" + echo "PNPM_CONFIG_STORE_DIR=$storeRoot" >> $env:GITHUB_ENV + - name: Install (immutable) shell: pwsh run: pnpm install --frozen-lockfile - - name: Run complete unsharded primary Node CI serially + - name: Run complete unsharded Windows gate inventory serially shell: pwsh env: DSH_COVERAGE_MAX_WORKERS: '1' - DSH_E2E_MAX_WORKERS: '1' DSH_GATE_CONCURRENCY: '1' - DSH_OXLINT_THREADS: '1' DSH_PUBLINT_CONCURRENCY: '1' - DSH_SNAPSHOT_MAX_CONCURRENCY: '1' - run: pnpm run check:ci + run: pnpm run check:ci:windows-complete # Manual, bounded comparison of the actual critical Linux and Windows lanes. # The named pools are restricted at the organization level to this repository. diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index 7a25b5223d..e324cfefc2 100644 --- a/.github/workflows/issue-lifecycle.yml +++ b/.github/workflows/issue-lifecycle.yml @@ -36,6 +36,7 @@ concurrency: jobs: lifecycle: name: Issue lifecycle + if: ${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }} runs-on: ubuntu-latest steps: - name: Check out trusted policy diff --git a/.github/workflows/landlock-run-release.yml b/.github/workflows/landlock-run-release.yml index 7d78e98bc6..8448af1ad1 100644 --- a/.github/workflows/landlock-run-release.yml +++ b/.github/workflows/landlock-run-release.yml @@ -172,5 +172,7 @@ jobs: tag_args=() case "$version" in *-*) tag_args=(--tag next);; esac while IFS= read -r tarball; do - npm publish "dist/npm/${tarball}" --access public "${tag_args[@]}" + # No --access: publishConfig.access in each manifest decides, and a + # command-line flag would override it. + npm publish "dist/npm/${tarball}" "${tag_args[@]}" done < dist/npm/publish-order.txt diff --git a/.github/workflows/release-vendor.yml b/.github/workflows/release-vendor.yml new file mode 100644 index 0000000000..c8af47251e --- /dev/null +++ b/.github/workflows/release-vendor.yml @@ -0,0 +1,132 @@ +# Pack and publish the vendored framework sequence: the nine rescoped Cordis +# packages under vendor/, each on its own version line. This sequence releases +# independently of dsh and of the native packages. +# +# Pack runs without credentials on every pull request and master push. +# Publication is a manual dispatch from a vendor-* tag; a vendor release can +# carry several versions, so each package has its own tag. +name: Release (vendor) + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + inputs: + publish: + description: Publish the packed tarballs to npm. Must run from a vendor-* tag. + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + # Pack runs per ref so concurrent pull requests never displace each + # other; the publish job below serializes the shared dist-tag state. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + PRIMARY_NODE_VERSION: '24' + DSH_TELEMETRY_DISABLED: '1' + +jobs: + pack: + name: Pack npm tarballs + runs-on: ubuntu-24.04 + steps: + # Complete history: the release scripts read tags. + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + - uses: actions/cache/restore@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Verify release version + env: + RELEASE_PUBLISH: ${{ inputs.publish }} + run: pnpm run release:verify --family vendor + + # The vendored packages publish their own sources and build outputs; the + # host build produces what their manifests select. + - name: Build + run: pnpm run build:lib:host + + - name: Pack release tarballs + run: pnpm run release:pack --family vendor --out dist/npm-vendor + + - name: Verify packed install + run: pnpm run release:verify-packed-install --family vendor --from dist/npm-vendor + + - uses: actions/upload-artifact@v4 + with: + name: vendor-npm-tarballs + path: dist/npm-vendor/* + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish to npm + if: inputs.publish + needs: pack + runs-on: ubuntu-24.04 + environment: npm-publish + concurrency: + group: Release-publish + cancel-in-progress: false + permissions: + contents: read + steps: + # Checkout and install carry the release scripts only; no build step. + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + registry-url: https://registry.npmjs.org + + - name: Install (immutable, no package scripts) + run: pnpm install --frozen-lockfile --ignore-scripts + + - uses: actions/download-artifact@v4 + with: + name: vendor-npm-tarballs + path: dist/npm-vendor + + - name: Publish tarballs + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm run release:publish --family vendor --from dist/npm-vendor diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000..cab178a3d2 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,147 @@ +# Pack and publish the dsh release sequence: every package under packages/ plus +# the apps/ entries, all on one version. The vendored framework and the native +# packages are separate sequences with their own workflows and version lines. +# +# Pack runs without credentials on every pull request and master push, so a +# pull request proves the whole publish set still packs. Publication is a +# manual dispatch from a dsh-v* tag and consumes exactly the packed bytes. +name: Release (dsh) + +on: + pull_request: + push: + branches: [master] + workflow_dispatch: + inputs: + publish: + description: Publish the packed tarballs to npm. Must run from a dsh-v* tag. + required: true + type: boolean + default: false + +permissions: + contents: read + +concurrency: + # Pack runs per ref so concurrent pull requests never displace each + # other; the publish job below serializes the shared dist-tag state. + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: false + +env: + PRIMARY_NODE_VERSION: '24' + DSH_TELEMETRY_DISABLED: '1' + +jobs: + pack: + name: Pack npm tarballs + runs-on: ubuntu-24.04 + steps: + # Complete history: the release scripts read tags. + - uses: actions/checkout@v6 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + + - name: Configure pnpm store path + id: pnpm-store + run: | + store_root="$HOME/.local/share/pnpm/store" + echo "PNPM_CONFIG_STORE_DIR=$store_root" >> "$GITHUB_ENV" + store_path=$(PNPM_CONFIG_STORE_DIR="$store_root" pnpm store path --silent) + echo "path=$store_path" >> "$GITHUB_OUTPUT" + + - uses: actions/cache/restore@v4 + with: + path: ${{ steps.pnpm-store.outputs.path }} + key: ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm-${{ hashFiles('pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-node-${{ env.PRIMARY_NODE_VERSION }}-pnpm- + + - name: Install (immutable) + run: pnpm install --frozen-lockfile + + - name: Verify release version + env: + RELEASE_PUBLISH: ${{ inputs.publish }} + run: pnpm run release:verify --family dsh + + - name: Build + run: pnpm run build + + - name: Pack release tarballs + run: pnpm run release:pack --family dsh --out dist/npm + + # The harness packages declare the vendored framework as a peer, and this + # job has no credentials for the private registry, so the verification + # installs that family's pack output too. Only dist/npm is published. + - name: Pack the vendored framework for verification + run: pnpm run release:pack --family vendor --out dist/npm-vendor + + # dsh-sandbox-local declares the Landlock entry as a runtime dependency, so + # the verification needs its tarball. Its platform packages stay out: they + # are optional, and building them needs a musl toolchain per architecture. + - name: Pack the Landlock entry for verification + run: | + pnpm --dir native/landlock-run run build:ts + pnpm --dir native/landlock-run/packages/entry pack --pack-destination "$PWD/dist/npm-landlock" + + - name: Verify packed install + run: pnpm run release:verify-packed-install --family dsh --from dist/npm --from dist/npm-vendor --from dist/npm-landlock + + - uses: actions/upload-artifact@v4 + with: + name: dsh-npm-tarballs + path: dist/npm/* + if-no-files-found: error + retention-days: 7 + + publish: + name: Publish to npm + if: inputs.publish + needs: pack + runs-on: ubuntu-24.04 + # Required reviewers and the allowed tags live on the environment; this is + # the only step in the sequence that can write to the registry. + environment: npm-publish + concurrency: + group: Release-publish + cancel-in-progress: false + permissions: + contents: read + steps: + # Checkout and install carry the release scripts only. There is no build + # step: publication uploads the bytes the pack job produced. + - uses: actions/checkout@v6 + with: + persist-credentials: false + + - uses: pnpm/action-setup@v4 + with: + dest: ${{ runner.temp }}/setup-pnpm + + - uses: actions/setup-node@v6 + with: + node-version: ${{ env.PRIMARY_NODE_VERSION }} + registry-url: https://registry.npmjs.org + + - name: Install (immutable, no package scripts) + run: pnpm install --frozen-lockfile --ignore-scripts + + - uses: actions/download-artifact@v4 + with: + name: dsh-npm-tarballs + path: dist/npm + + - name: Publish tarballs + env: + NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} + run: pnpm run release:publish --family dsh --from dist/npm diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index b4af9e3ecb..fd56278195 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -42,7 +42,7 @@ sdk-wheel: - uv run --python 3.10 --group test --project python/sdk python scripts/smoke-python-runtime.py --scenario all --exe "$EXE" - python scripts/build-python-release.py --package runtime --tag "$CI_COMMIT_TAG" --platform "$PLATFORM" --runtime-exe "$EXE" --output-dir "release/$PLATFORM" - python -m venv .wheel-smoke - - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness=="$DSH_VERSION" + - .wheel-smoke/bin/python -m pip install --find-links "release/$PLATFORM" --find-links release/sdk deepseek-harness-sdk=="$DSH_VERSION" - .wheel-smoke/bin/python scripts/smoke-python-runtime.py --scenario sdk-default - | if [ "${PLATFORM#linux-}" != "$PLATFORM" ]; then @@ -55,7 +55,7 @@ sdk-wheel: linux-arm64) image=quay.io/pypa/manylinux_2_28_aarch64 ;; *) echo "Unsupported Linux platform $PLATFORM"; exit 1 ;; esac - docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness==$DSH_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" + docker run --rm -v "$PWD:/work" -w /work "$image" bash -euxo pipefail -c "/opt/python/cp310-cp310/bin/python -m venv /tmp/dsh-sdk && /tmp/dsh-sdk/bin/python -m pip install --find-links /work/release/$PLATFORM --find-links /work/release/sdk deepseek-harness-sdk==$DSH_VERSION && /tmp/dsh-sdk/bin/python /work/scripts/smoke-python-runtime.py --scenario sdk-default" fi artifacts: paths: [release/$PLATFORM/*.whl] @@ -112,7 +112,7 @@ publish-python: - python -m pip install twine==6.2.0 script: - test "$(find release -name '*.whl' | wc -l | tr -d ' ')" = 4 - - test -f "release/sdk/deepseek_harness-${DSH_VERSION}-py3-none-any.whl" + - test -f "release/sdk/deepseek_harness_sdk-${DSH_VERSION}-py3-none-any.whl" - test -f "release/linux-x64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_x86_64.whl" - test -f "release/linux-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-manylinux_2_28_aarch64.whl" - test -f "release/macos-arm64/deepseek_harness_runtime_bin-${DSH_VERSION}-py3-none-macosx_11_0_arm64.whl" diff --git a/AGENTS.md b/AGENTS.md index 9daf45a832..15d0863344 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -69,11 +69,11 @@ pnpm run typecheck pnpm run lint pnpm run duplication # cross-file TypeScript clone detection pnpm run build # tsc emits lib/types, tsdown bundles runtime -pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal pnpm run hygiene # knip + publint + workspace constraints + NodeNext consumer check +pnpm run check:windows-wine # ONLY when diagnosing a known Windows failure (needs wine); CI owns this signal pnpm run doc-sync # all documentation gates; leaf list in scripts/run-gates.ts pnpm run website:build # VitePress build (doubles as dead-link check) -pnpm run demo:headless "task" # one-shot agent (needs DEEPSEEK_API_KEY) +pnpm dsh --profile headless "task" # build, then run one task (needs DEEPSEEK_API_KEY) pnpm run demo:cordis # the agent modifies its own runtime (needs key) pnpm run demo:acp # ACP automation server (needs DEEPSEEK_API_KEY) ``` @@ -96,11 +96,11 @@ Real-API tests and demos read `DEEPSEEK_API_KEY`, optional `DEEPSEEK_BASE_URL`, ## Conventions -- Every npm package is `@deepseek-ai/dsh-`; vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package. +- Every npm package is `@deepseek-ai/dsh-`; vendored packages are rescoped ([mapping](docs/rescope.md)) and `private: true`. `@deepseek-ai/cordis` is a peerDependency (+ dev) of every harness package. - ESM everywhere (`"type": "module"`). Use package names across packages and `.ts` in local relative imports. Config subprocesses run built `lib/` under plain Node; source regressions use their declared launcher ([testing policy](docs/testing.md#test-subprocess-launch-modes)). The `dsh` CLI source launch runs through tsx's ESM-only hook (`node --import tsx/esm`); modules it reaches must stay ESM (no CJS-only exports) — Node's native TypeScript modes are unavailable across the engines range ([source-launch contract](.agents/notes/implemented/architecture/2026-07-29-dsh-source-launch-tsx-esm.md)). Raw/Web `cordis.yml` bare plugins must appear in their resolver manifest's `dependencies`; `verify-cordis-config` enforces it. - **Registrations are effects**: every contribution goes through `ctx.effect()` / `ctx.on()`; a registry's `register()` returns the disposer. - **Runtime invariants assert owned relationships.** Check authoritative event streams or mutable data, not service or method presence, plugin metadata or effects, or fixed pure examples. Without a plausible relationship, an explained empty companion is correct ([package invariant rules](packages/AGENTS.md)). -- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. +- **Typed events use declaration merging** and merge-extensible maps. Event JSDoc needs `@mode` and payload `@param`; scoped keys absent from payloads need `@dshScopeScan unsupported`. Public service methods document parameters and non-void returns. A `SessionEventMap` member is required-on-read by default — builds that do not know its type refuse the log unless the event carries the envelope's `ignorable: true`; only structural format changes bump `SESSION_FORMAT_VERSION` ([mechanism](.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)). - **Switch on discriminant tags.** Closed unions end in `assertNever`; merge-extensible unions fall through a documented default. - **Waterfall listeners MUST call `next()`** to delegate; returning without it short-circuits the chain ([semantics](docs/cordis-primer.md#cordis-waterfall-semantics)). - **Model-visible ⟺ logged**: anything that reaches a model request must be reconstructable from the session log; a new model-visible input requires a session event. diff --git a/BENCHMARK.md b/BENCHMARK.md new file mode 100644 index 0000000000..6e8f466a1f --- /dev/null +++ b/BENCHMARK.md @@ -0,0 +1,3 @@ +# Running benchmarks + +To run benchmark tasks with the minimal agent composition, follow [Get started with the Python SDK](docs/user/guide/python-sdk.md). The guide covers installation, running [`minimal.cordis.yml`](examples/jsonrpc-agent/minimal.cordis.yml), and isolating workspaces and session IDs between tasks. diff --git a/README.i18n.yaml b/README.i18n.yaml index 35cb82e776..552fb03a25 100644 --- a/README.i18n.yaml +++ b/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 README.md -README.md: 3174630d021b3868986d6ad9989d257fe8ac29fb -README.zh.md: 377ea6372a9a531c1400d08b0ef33792b452dc65 +README.md: b2d84672275a4ca996b6ea596c104abfaa52432a +README.zh.md: 747a3c88bc129ad5dd24f5fa150a1661c4db0b11 diff --git a/README.md b/README.md index 3174630d02..b2d8467227 100644 --- a/README.md +++ b/README.md @@ -12,41 +12,34 @@ DeepSeek Harness is under internal testing. Features and interfaces may change. The internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group. -## Install +## Run from source -Clone the repository, then run the installer: +Clone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run: ```sh -git clone -cd deepseek-harness -scripts/install.sh +pnpm dsh web ``` -The installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI. - -The default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options. - ## Use DeepSeek Harness ### Web UI -For the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run: +Start the recommended local interface from the repository root: ```sh -(cd ~/.dsh/source/current && pnpm run build) -dsh web +pnpm dsh web ``` -The path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default. +The command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default. ### Profiles -`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`: +The source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`: ```sh -dsh --profile web # the browser UI (same as: dsh web) -dsh plugin --profile tui add # install a plugin into a custom profile -dsh --profile tui # boot it +pnpm dsh --profile web # the browser UI +pnpm dsh plugin --profile tui add # install a plugin into a custom profile +pnpm dsh --profile tui # boot it ``` The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands. @@ -56,7 +49,7 @@ The [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer Run one task, print the final answer, and exit: ```sh -dsh run "summarize this workspace" +pnpm dsh --profile headless "summarize this workspace" ``` ### Automation and SDKs diff --git a/README.zh.md b/README.zh.md index 377ea6372a..747a3c88bc 100644 --- a/README.zh.md +++ b/README.zh.md @@ -12,41 +12,34 @@ DeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化 为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。 -## 安装 +## 从源码运行 -克隆仓库,然后运行安装器: +克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行: ```sh -git clone -cd deepseek-harness -scripts/install.sh +pnpm dsh web ``` -安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。 - -默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。 - ## 使用 DeepSeek Harness ### Web UI -推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行: +请从仓库根目录启动推荐的本地界面: ```sh -(cd ~/.dsh/source/current && pnpm run build) -dsh web +pnpm dsh web ``` -上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 +该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。 ### Profile -`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层: +源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层: ```sh -dsh --profile web # the browser UI (same as: dsh web) -dsh plugin --profile tui add # install a plugin into a custom profile -dsh --profile tui # boot it +pnpm dsh --profile web # the browser UI +pnpm dsh plugin --profile tui add # install a plugin into a custom profile +pnpm dsh --profile tui # boot it ``` profile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。 @@ -56,7 +49,7 @@ profile 布局、层语义与配置输出命令详见 [CLI(命令行界面) 运行一项任务,打印最终答案后退出: ```sh -dsh run "summarize this workspace" +pnpm dsh --profile headless "summarize this workspace" ``` ### 自动化与 SDK diff --git a/THIRD_PARTY_NOTICES.md b/THIRD_PARTY_NOTICES.md index fec5de6128..e0c71e9903 100644 --- a/THIRD_PARTY_NOTICES.md +++ b/THIRD_PARTY_NOTICES.md @@ -11,23 +11,23 @@ The complete npm transitive closure, including the Landlock launcher workspace, ## Vendored source (`vendor/`) -The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed; each directory preserves its upstream `LICENSE` file. Exact upstream commits and local modifications are recorded in [`vendor/README.md`](vendor/README.md). +The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the `@deepseek-ai` scope. All are MIT-licensed; each directory preserves its upstream `LICENSE` file. Exact upstream commits and local modifications are recorded in [`vendor/README.md`](vendor/README.md). -| Package | Upstream | License | -| --- | --- | --- | -| `cosmokit` | [github.com/deepseek-harness/cosmokit](https://github.com/deepseek-harness/cosmokit) | MIT | -| `schemastery` | [github.com/deepseek-harness/schemastery](https://github.com/deepseek-harness/schemastery) | MIT | -| `cordis` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT | -| `@cordisjs/plugin-loader` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT | -| `@cordisjs/plugin-include` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | -| `@cordisjs/plugin-group` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | -| `@cordisjs/plugin-timer` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | -| `@cordisjs/plugin-hmr` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | -| `@cordisjs/plugin-logger-console` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| Package | Upstream name | Upstream | License | +| --- | --- | --- | --- | +| `@deepseek-ai/cosmokit` | `cosmokit` | [github.com/deepseek-harness/cosmokit](https://github.com/deepseek-harness/cosmokit) | MIT | +| `@deepseek-ai/schemastery` | `schemastery` | [github.com/deepseek-harness/schemastery](https://github.com/deepseek-harness/schemastery) | MIT | +| `@deepseek-ai/cordis` | `cordis` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT | +| `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | [github.com/cordiverse/cordis](https://github.com/cordiverse/cordis) | MIT | +| `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | +| `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | [github.com/deepseek-harness/cordis](https://github.com/deepseek-harness/cordis) | MIT | ## Runtime npm dependencies -External packages that a workspace package resolves at runtime. `scripts/install.sh` installs this repository itself, so the tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. +External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from `cordis.yml` — not only what the `dsh` CLI, Web UI, and Python SDK runtime load by default. | Package | License | | --- | --- | @@ -59,6 +59,7 @@ External packages that a workspace package resolves at runtime. `scripts/install | [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause | | [`e2b`](https://github.com/e2b-dev/e2b) | MIT | | [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT | +| [`fflate`](https://github.com/101arrowz/fflate) | MIT | | [`handlebars`](https://github.com/handlebars-lang/handlebars.js) | MIT | | [`immer`](https://github.com/immerjs/immer) | MIT | | [`js-yaml`](https://github.com/nodeca/js-yaml) | MIT | @@ -80,7 +81,6 @@ 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 | | [`sharp`](https://github.com/lovell/sharp) | Apache-2.0 | @@ -181,7 +181,7 @@ Direct dependencies of the `pyproject.toml` manifests, plus `uv` as the developm | Package | License | Role | | --- | --- | --- | | [`hatchling`](https://github.com/pypa/hatch) | MIT | build backend | -| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness` | +| [`pydantic`](https://github.com/pydantic/pydantic) | MIT | runtime dependency of `deepseek-harness-sdk` | | [`pytest`](https://github.com/pytest-dev/pytest) | MIT | test-only | | [`uv`](https://github.com/astral-sh/uv) | MIT / Apache-2.0 | development workflow tool | diff --git a/apps/cli/README.i18n.yaml b/apps/cli/README.i18n.yaml index 101332555d..191d6c8e68 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: dd29f7fc03a783079ea3194de99589c1f545be5b -README.zh.md: 60e7aa1ec1ea2fad7e3f3d97a0f6bf42355adffc +README.md: 98a856261bc632c97f350db8fc7bb0b10c22235d +README.zh.md: 283e54138e24202ed6b88d1d309538c58cd66b3e diff --git a/apps/cli/README.md b/apps/cli/README.md index dd29f7fc03..98a856261b 100644 --- a/apps/cli/README.md +++ b/apps/cli/README.md @@ -9,18 +9,30 @@ The `dsh` command is the product launcher for profiles: ordered stacks of plugin | Command | Purpose | |---|---| | `dsh --profile ` | Boot the named profile under `$DSH_HOME/profiles/`. | -| `dsh run [--profile ] [--patch ...] "task"` | Run one fresh persisted session directly over core, print the final answer, and exit; the profile defaults to `headless` and mounts no Web server. | -| `dsh web` | Alias of `--profile web` with the Web flag family (`--host`, `--port`, `--dev`, ...). | +| `dsh --profile headless "task"` | Run one fresh persisted session, print the final answer, and exit. | +| `dsh web` | Alias of `--profile web`. | | `dsh plugin --profile ` | Manage a profile's plugins by forwarding to pnpm in the profile directory. | -The invoking directory is the default workspace root. `dsh run` requires non-blank task text and the selected profile must mount the `headless-runner` row; `--profile` preserves custom one-shot profiles. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. +The invoking directory is the default workspace root. The `web` and `headless` profiles auto-initialize on first use from shipped templates; any other profile must be created through `dsh plugin`. + +## App arguments + +The launcher parses only its own flags and hands everything after them to the booted profile, where any injected app plugin may parse the shared immutable snapshot ([`dsh-cmdline`](../../packages/boot/cmdline/README.md)). Launcher flags therefore come first, and the first token the launcher does not recognize starts the app's arguments: + +```sh +dsh --profile web --port 8080 # --port belongs to the web app +dsh --profile tui --resume # --resume belongs to the terminal app +dsh --profile headless "run the tests" +dsh --profile web --help # the web app's flags, not the launcher's +dsh --help # the launcher's own help +``` ## Profiles -A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays, then flag patches. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. +A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it. -The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher. +The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution. ## Development -Production runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract. +Production runs require built package and frontend artifacts. From the repository root, `pnpm dsh ` builds those artifacts, runs the TypeScript entry, and forwards every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract. diff --git a/apps/cli/README.zh.md b/apps/cli/README.zh.md index 60e7aa1ec1..283e54138e 100644 --- a/apps/cli/README.zh.md +++ b/apps/cli/README.zh.md @@ -9,18 +9,30 @@ | 命令 | 用途 | |---|---| | `dsh --profile ` | 启动位于 `$DSH_HOME/profiles/` 的指定 profile。 | -| `dsh run [--profile ] [--patch ...] "task"` | 直接在 core 上运行一个新的持久化会话,打印最终答案并退出;profile 默认为 `headless`,且不挂载 Web server。 | -| `dsh web` | `--profile web` 的别名,附带 Web flag 系列(`--host`、`--port`、`--dev` 等)。 | +| `dsh --profile headless "task"` | 运行一个新的持久化会话,打印最终答案并退出。 | +| `dsh web` | `--profile web` 的别名。 | | `dsh plugin --profile ` | 通过在 profile 目录中转发给 pnpm 来管理该 profile 的插件。 | -调用目录是默认 workspace 根目录。`dsh run` 要求任务文本非空白,且所选 profile 必须挂载 `headless-runner` 行;`--profile` 保留对自定义一次性 profile 的支持。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 +调用目录是默认 workspace 根目录。`web` 和 `headless` profile 在首次使用时会从随附模板自动初始化;其他任何 profile 都必须通过 `dsh plugin` 创建。 + +## 应用参数 + +启动器只解析属于自己的 flag,并把其后的一切交给启动起来的 profile,任何注入它的应用插件都可以解析这份共享的不可变快照([`dsh-cmdline`](../../packages/boot/cmdline/README.md))。因此启动器的 flag 必须写在前面,而启动器不认识的第一个 token 就是应用参数的起点: + +```sh +dsh --profile web --port 8080 # --port belongs to the web app +dsh --profile tui --resume # --resume belongs to the terminal app +dsh --profile headless "run the tests" +dsh --profile web --help # the web app's flags, not the launcher's +dsh --help # the launcher's own help +``` ## Profile -profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay,最后是 flag patch。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 +profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。 -[CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器。 +[CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码执行。 ## 开发 -生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析约定。 +生产运行需要已构建的包与前端产物。从仓库根目录运行 `pnpm dsh ` 会先构建这些产物,再运行 TypeScript 入口并转发所有参数;模块解析约定由[源码执行参考](reference/README.md#source-execution)负责。 diff --git a/apps/cli/composition.md b/apps/cli/composition.md index aef0dc6a7b..303903566b 100644 --- a/apps/cli/composition.md +++ b/apps/cli/composition.md @@ -8,12 +8,10 @@ The dsh-base bundle patch every profile applies first; mode bundles (dsh-web-app ```mermaid flowchart LR cfg["packages/bundle/base/cordis.patch.yml
cordis.yml"] - plugin_dsh_base_timer["timer
@cordisjs/plugin-timer"] + plugin_dsh_base_timer["timer
@deepseek-ai/cordis-plugin-timer"] cfg --> plugin_dsh_base_timer - plugin_dsh_base_hmr["hmr
@cordisjs/plugin-hmr"] + plugin_dsh_base_hmr["hmr
@deepseek-ai/cordis-plugin-hmr"] cfg --> plugin_dsh_base_hmr - plugin_dsh_base_repository_plugins["repository-plugins
@deepseek-ai/dsh-repository-plugin"] - cfg --> plugin_dsh_base_repository_plugins plugin_dsh_base_llm["llm
@deepseek-ai/dsh-llm"] cfg --> plugin_dsh_base_llm plugin_dsh_base_session["session
@deepseek-ai/dsh-session"] @@ -112,6 +110,10 @@ flowchart LR cfg --> plugin_dsh_base_subagent_spawn plugin_dsh_base_subagent_fork["subagent-fork
@deepseek-ai/dsh-subagent-fork"] cfg --> plugin_dsh_base_subagent_fork + plugin_dsh_base_subagent_codex["subagent-codex
@deepseek-ai/dsh-subagent-codex"] + cfg --> plugin_dsh_base_subagent_codex + plugin_dsh_base_subagent_claude_code["subagent-claude-code
@deepseek-ai/dsh-subagent-claude-code"] + cfg --> plugin_dsh_base_subagent_claude_code plugin_dsh_base_tool_subagent_control["tool-subagent-control
@deepseek-ai/dsh-tool-subagent-control"] cfg --> plugin_dsh_base_tool_subagent_control plugin_dsh_base_tool_subagent_list_agents["tool-subagent-list-agents
@deepseek-ai/dsh-tool-subagent-control/list-agents"] @@ -166,9 +168,8 @@ flowchart LR | Plugin id | Package / module | | --- | --- | -| `timer` | `@cordisjs/plugin-timer` | -| `hmr` | `@cordisjs/plugin-hmr` | -| `repository-plugins` | `@deepseek-ai/dsh-repository-plugin` | +| `timer` | `@deepseek-ai/cordis-plugin-timer` | +| `hmr` | `@deepseek-ai/cordis-plugin-hmr` | | `llm` | `@deepseek-ai/dsh-llm` | | `session` | `@deepseek-ai/dsh-session` | | `typert` | `@deepseek-ai/dsh-typert-registry` | @@ -218,6 +219,8 @@ flowchart LR | `subagent` | `@deepseek-ai/dsh-subagent` | | `subagent-spawn` | `@deepseek-ai/dsh-subagent-spawn` | | `subagent-fork` | `@deepseek-ai/dsh-subagent-fork` | +| `subagent-codex` | `@deepseek-ai/dsh-subagent-codex` | +| `subagent-claude-code` | `@deepseek-ai/dsh-subagent-claude-code` | | `tool-subagent-control` | `@deepseek-ai/dsh-tool-subagent-control` | | `tool-subagent-list-agents` | `@deepseek-ai/dsh-tool-subagent-control/list-agents` | | `tool-subagent` | `@deepseek-ai/dsh-tool-subagent` | diff --git a/apps/cli/config/agent-presets/code/agent.cordis.yml b/apps/cli/config/agent-presets/code/agent.cordis.yml index 5e273bb955..c99e300f55 100644 --- a/apps/cli/config/agent-presets/code/agent.cordis.yml +++ b/apps/cli/config/agent-presets/code/agent.cordis.yml @@ -53,7 +53,7 @@ # ── filesystem ────────────────────────────────────────────────────────────── -# All three register into the host `tools` registry and provide nothing, so +# Both register into the host `tools` registry and provide nothing, so # they need no realm. The `fs` service and its policy stay in the host. - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' @@ -63,11 +63,6 @@ config: sampleOverCapGlobResults: false -- id: tool-str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - # ── background tasks ──────────────────────────────────────────────────────── # Only the model-facing controls. The task REGISTRY stays on the host plane: @@ -134,17 +129,20 @@ # `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must # share this realm rather than sit outside it. +# +# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST +# plane, and the rows here resolve that one instance. It takes no configuration, +# keys every fold by Session, and owns the context-meter projection units the +# browser reads for every session — behind a realm those units would come and go +# with whichever presets happen to be mounted. What a preset chooses is whether +# its agent compacts at all, which is `compact-basic` below. - id: compaction name: cordis:group group: true isolate: - tokenMeter: true compact: true toolResultPrune: true config: - - id: token-meter - name: '@deepseek-ai/dsh-token-meter' - - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' @@ -195,6 +193,27 @@ toolName: subagent_fork backgroundMode: continuable + # Product providers are host-plane singletons. Copy this preset, then + # remove `disabled` from either ordinary tool row to expose that product + # only to agents composed from the copy. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed + - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: diff --git a/apps/cli/config/agent-presets/cordis/agent.cordis.yml b/apps/cli/config/agent-presets/cordis/agent.cordis.yml index 1bc3d3bcf0..6fa6030c97 100644 --- a/apps/cli/config/agent-presets/cordis/agent.cordis.yml +++ b/apps/cli/config/agent-presets/cordis/agent.cordis.yml @@ -47,7 +47,7 @@ # ── filesystem ────────────────────────────────────────────────────────────── -# All three register into the host `tools` registry and provide nothing, so +# Both register into the host `tools` registry and provide nothing, so # they need no realm. The `fs` service and its policy stay in the host. - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' @@ -57,11 +57,6 @@ config: sampleOverCapGlobResults: false -- id: tool-str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - # ── background tasks ──────────────────────────────────────────────────────── # Only the model-facing controls. The task REGISTRY stays on the host plane: @@ -115,17 +110,20 @@ # `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must # share this realm rather than sit outside it. +# +# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST +# plane, and the rows here resolve that one instance. It takes no configuration, +# keys every fold by Session, and owns the context-meter projection units the +# browser reads for every session — behind a realm those units would come and go +# with whichever presets happen to be mounted. What a preset chooses is whether +# its agent compacts at all, which is `compact-basic` below. - id: compaction name: cordis:group group: true isolate: - tokenMeter: true compact: true toolResultPrune: true config: - - id: token-meter - name: '@deepseek-ai/dsh-token-meter' - - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' @@ -182,6 +180,27 @@ toolName: subagent_fork backgroundMode: continuable + # Product providers are host-plane singletons. Copy this preset, then + # remove `disabled` from either ordinary tool row to expose that product + # only to agents composed from the copy. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed + - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: diff --git a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md index f6d9a6126c..ce751f039d 100644 --- a/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md +++ b/apps/cli/config/agent-presets/cordis/skills/editing-cordis-compositions/SKILL.md @@ -26,6 +26,34 @@ A preset is a directory holding one `agent.cordis.yml`, optionally beside a `pre 3. **Rewrite `preset.yml`**: give the copy its own `name` and `description`, and drop any `order` the source declared — that field sorts the shipped roster. 4. **Edit `agent.cordis.yml`** row by row, keeping the plane rule and realm rule above. +### Native product subagents + +Codex and Claude Code providers already live in the host composition. A preset chooses either product by contributing the same ordinary delegation-tool row used for spawn and fork; never move a product provider into the preset and never add a product-specific settings field. + +Copy these disabled templates from a shipped full preset and remove `disabled` only for the products the user requested: + +```yaml +- id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + +- id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed +``` + +The two rows are independent. Leaving both disabled preserves the copied preset, enabling one exposes only that product tool, and enabling both exposes both. The host must provide `codex` or `claude` on `PATH`; the preset does not install, authenticate, select a model for, or probe either product. + The shipped preset directories are off-limits: never edit or delete them, and never escalate the sandbox to reach them, even when a change there looks quicker — an upgrade overwrites the install, and corrupting the `cordis` preset disables preset authoring itself. Locally authored presets under the user root are yours to create, edit, and delete. ## The rule that catches people diff --git a/apps/cli/config/agent-presets/minimal/agent.cordis.yml b/apps/cli/config/agent-presets/minimal/agent.cordis.yml index 6ae88b9339..1ec0a6ea75 100644 --- a/apps/cli/config/agent-presets/minimal/agent.cordis.yml +++ b/apps/cli/config/agent-presets/minimal/agent.cordis.yml @@ -1,39 +1,73 @@ -# The `minimal` agent preset: the two-tool benchmark surface. +# The `minimal` agent preset: a fixed-prompt, two-tool coding surface. # -# The native model surface is exactly persistent `bash` plus -# `str_replace_editor`. Everything else a session could reach — skills, goals, -# plan mode, delegation, workflows, todo, web — is simply absent rather than -# disabled, because a preset composes what an agent has instead of subtracting -# from a shared default. -# -# The host composition is unchanged: this agent still runs inside the same -# sandbox, approval, persistence, and model routing as any other session. +# The persona is the complete system prompt, so global identity, Web surface, +# tool guidance, and later assembly listeners cannot add prompt text. The model +# composes only the persistent `bash` and `str_replace_editor` tools. - id: persona name: '@deepseek-ai/dsh-persona' config: - text: >- - You are a coding agent powered by the {{model}} model. Your working directory is {{cwd}}. + text: You are a helpful software engineer assistant. + complete: true -# `bash-env` stays in the HOST composition: `apps/cli/src/web.ts` injects it to -# publish `DSH_WEB_URL`/`DSH_WEB_MODE`, and a host row that injects a service is -# the criterion for host-plane ownership — injection resolves before any session -# exists, so there is no agent to key by. Behind a preset realm those variables -# never reached the model's shell at all. `tool-bash` consumes the host registry -# from here; the executor behind it (`bash-sandbox`) is host-plane too, where the -# sandbox policy owns it. -# -# `run_in_background` is off because this preset mounts no `tool-tasks`. The -# host registry already refuses a start for an owner no attached control -# surface serves, so this is not the safety boundary — it is the model-facing -# one: an agent that could never collect a task should not be offered the -# parameter at all, and disabling it drops the parameter from the schema. -- id: tool-bash - name: '@deepseek-ai/dsh-tool-bash' +# The PTY registry is an agent-owned service, so it lives in an entry-local +# realm. The backend still consumes the host sandbox policy and subprocess +# implementation, while the tool registers into this agent's scoped catalog. +- id: persistent-shell + name: cordis:group + group: true + isolate: + pty: true config: - enableRunInBackground: false + - id: pty + name: '@deepseek-ai/dsh-pty' -- id: tool-str-replace-editor + - id: pty-local + name: '@deepseek-ai/dsh-pty-local' + config: + timeoutMs: 300000 + + - id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + +# The editor requires absolute paths unconditionally. +- id: str-replace-editor name: '@deepseek-ai/dsh-tool-str-replace-editor' config: maxOutputChars: 16000 + +# Model capacity comes from routed model metadata; this block states the +# compaction policy explicitly. +# +# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST +# plane, and the row here resolves that one instance. It takes no configuration, +# keys every fold by Session, and owns the context-meter projection units the +# browser reads for every session — behind a realm those units would come and go +# with whichever presets happen to be mounted. What a preset chooses is whether +# its agent compacts at all, which is `compact-basic` below. +- id: compaction + name: cordis:group + group: true + isolate: + compact: true + config: + - id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationProvider: '' + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 diff --git a/apps/cli/config/agent-presets/minimal/preset.yml b/apps/cli/config/agent-presets/minimal/preset.yml index 7160b51c43..97c714b72a 100644 --- a/apps/cli/config/agent-presets/minimal/preset.yml +++ b/apps/cli/config/agent-presets/minimal/preset.yml @@ -1,3 +1,3 @@ name: 极简模式 -description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 +description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 order: 3 diff --git a/apps/cli/config/agent-presets/standard/agent.cordis.yml b/apps/cli/config/agent-presets/standard/agent.cordis.yml index 6442b12306..fbef791a22 100644 --- a/apps/cli/config/agent-presets/standard/agent.cordis.yml +++ b/apps/cli/config/agent-presets/standard/agent.cordis.yml @@ -46,7 +46,7 @@ # ── filesystem ────────────────────────────────────────────────────────────── -# All three register into the host `tools` registry and provide nothing, so +# Both register into the host `tools` registry and provide nothing, so # they need no realm. The `fs` service and its policy stay in the host. - id: tool-fs name: '@deepseek-ai/dsh-tool-fs' @@ -56,11 +56,6 @@ config: sampleOverCapGlobResults: false -- id: tool-str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 - # ── background tasks ──────────────────────────────────────────────────────── # Only the model-facing controls. The task REGISTRY stays on the host plane: @@ -127,17 +122,20 @@ # `compact-basic` reads `toolResultPrune` through `ctx.get`, so the pruner must # share this realm rather than sit outside it. +# +# `tokenMeter` is deliberately NOT in this realm: the meter stays on the HOST +# plane, and the rows here resolve that one instance. It takes no configuration, +# keys every fold by Session, and owns the context-meter projection units the +# browser reads for every session — behind a realm those units would come and go +# with whichever presets happen to be mounted. What a preset chooses is whether +# its agent compacts at all, which is `compact-basic` below. - id: compaction name: cordis:group group: true isolate: - tokenMeter: true compact: true toolResultPrune: true config: - - id: token-meter - name: '@deepseek-ai/dsh-token-meter' - - id: compact-basic name: '@deepseek-ai/dsh-compact-basic' @@ -194,6 +192,27 @@ toolName: subagent_fork backgroundMode: continuable + # Product providers are host-plane singletons. Copy this preset, then + # remove `disabled` from either ordinary tool row to expose that product + # only to agents composed from the copy. + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + disabled: true + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed + - id: workflow-workerthread name: '@deepseek-ai/dsh-workflow-workerthread' config: diff --git a/apps/cli/config/core-web.cordis.yml b/apps/cli/config/core-web.cordis.yml deleted file mode 100644 index 43860418c4..0000000000 --- a/apps/cli/config/core-web.cordis.yml +++ /dev/null @@ -1,113 +0,0 @@ -# Opt-in Web shell for the RL core agent contract. The model receives exactly -# the configured persona plus the native `bash` and `str_replace_editor` -# schemas; the Web host, browser shell, persistence, and permission stack stay. - -# Match the Claude SWE-compatible RL core prompt. Disabling the Web runtime's -# surface context removes its GUI orientation, managed shell variables, and the -# launcher's source-checkout section through one configuration contract. -# Workspace instructions are model-visible user context rather than a system -# section, but RL core disables them as part of the same prompt contract. -- id: system-prompt - config: - includeHarnessIdentity: false - persona: !!js process.env.DSH_SYSTEM_PROMPT ?? 'You are a helpful software engineer assistant.' - -- id: web-runtime - config: - surfaceContext: false - -- id: workspace-context - disabled: true - -- id: tools - config: - mode: native - -# Disable every model-facing consumer in the base/Web tree. plan-mode owns the -# always-registered exit_plan_mode tool even while the session is not planning. -- id: tool-bash - disabled: true - -- id: tool-tasks - disabled: true - -- id: tool-fs - disabled: true - -- id: tool-fs-search - disabled: true - -- id: tool-web - disabled: true - -- id: tool-skill - disabled: true - -- id: plan-mode - disabled: true - -- id: tool-subagent-control - disabled: true - -- id: tool-subagent-list-agents - disabled: true - -- id: tool-subagent - disabled: true - -- id: tool-subagent-fork - disabled: true - -- id: tool-workflow - disabled: true - -- id: tool-todo - disabled: true - -# These consumers are shared defaults on the ordinary shipped surfaces, but -# this opt-in profile keeps exactly its two named tools. -- id: tool-goal - disabled: true - -- id: tool-ralph - disabled: true - -- id: tool-str-replace-editor - disabled: true - -# The matching browser controls must not offer surfaces whose tool this -# overlay omits: the panels would render for a capability the model does not -# have. Turning the row off no longer removes a tool — `ui-question`'s host -# half is empty and `tool-ask-user` is composed per preset — so this is a UI -# decision now, not a capability one. -- id: ui-plan - disabled: true - -- id: ui-question - disabled: true - -- insert: - - id: pty - name: '@deepseek-ai/dsh-pty' - - # This backend consumes the existing Web sandbox and permission policy. - # It loads only on Linux/macOS; Windows and other platforms fail at boot. - # Its 300s send wait matches the persistent Bash command timeout instead of - # pty-local's 30s default. An open persistent shell fences permission-mode - # changes until it closes. - - id: pty-local - name: '@deepseek-ai/dsh-pty-local' - config: - timeoutMs: 300000 - - - id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - config: - timeoutMs: 300000 - - # The editor consumes the Web fs-sandbox provider and therefore retains - # the selected session permission mode. - - id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - config: - maxOutputChars: 16000 diff --git a/apps/cli/package.json b/apps/cli/package.json index 03bd4c3db9..772ad7de32 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "apps/cli" + }, "type": "module", "bin": { "dsh": "lib/bin.js" @@ -13,10 +20,10 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@cordisjs/plugin-hmr": "workspace:*", - "@cordisjs/plugin-include": "workspace:*", - "@cordisjs/plugin-loader": "workspace:*", - "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/cordis-plugin-hmr": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent-tool-mode": "workspace:^", "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-base": "workspace:^", @@ -27,6 +34,8 @@ "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-headless": "workspace:^", "@deepseek-ai/dsh-mcp-client": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", @@ -36,6 +45,7 @@ "@deepseek-ai/dsh-pty-local": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-pwsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-session-reference": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", @@ -63,7 +73,7 @@ "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "commander": "^15.0.0", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "js-yaml": "^4.2.0", "node-addon-require-builtin": "^0.1.4" }, diff --git a/apps/cli/reference/README.i18n.yaml b/apps/cli/reference/README.i18n.yaml index 4b5aed6cd2..a2cfed084a 100644 --- a/apps/cli/reference/README.i18n.yaml +++ b/apps/cli/reference/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/reference/README.md -README.md: 0b5faf8993cd8065fffcfec5f240b0084508db91 -README.zh.md: b9c48c16dd4be186266d30a438329463c31aca70 +README.md: cef687dc392f97886ea18b162e8f668a44ce2284 +README.zh.md: f6721ec256d404e2b602fb2ed921b41c03633a82 diff --git a/apps/cli/reference/README.md b/apps/cli/reference/README.md index 0b5faf8993..cef687dc39 100644 --- a/apps/cli/reference/README.md +++ b/apps/cli/reference/README.md @@ -2,17 +2,32 @@ English | [中文](README.zh.md) -This reference defines the profile, one-shot run, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. +This reference defines the profile, web-alias, plugin-management, and config-dump command modes. Argv is parsed once through [`src/args.ts`](../src/args.ts), and [`src/bin.ts`](../src/bin.ts) dynamically imports only the selected runner. ## Profile boot -`dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), each `--patch ` overlay in argv order, and launcher flag patches. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. +`dsh --profile ` boots the profile at `$DSH_HOME/profiles/`. The effective tree is composed over an empty root by applying, in order: each bundle patch named in the profile manifest's `dsh.profile.bundles` list, the profile's own `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml` (machine-local preferences shared by every profile, so it outranks the per-profile layer), and each `--patch ` overlay in argv order. Later layers win per row; a patch replaces the targeted row's complete `config` value rather than deep-merging keys, and may insert new rows. A parse, schema, resolution, or plugin boot failure is reported and exits nonzero. SIGINT and SIGTERM dispose the mounted root before exit. Bundle names resolve from the dsh installation first, then from the profile directory. In-box bundles (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`) therefore always come from the same installation as the running `dsh`; out-of-tree bundles come from the profile's pnpm-managed `node_modules`. A bare plugin `name` in any patch row resolves through the profile directory's Node parent-walk, which reaches the maintained installation fallback `$DSH_HOME/profiles/node_modules` (one symlink per package the installation's app and bundles depend on, healed on every launch). -The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). On load, the exact installation-owned headless tuple (base + web-app + headless) normalizes to the shipped template; extra, missing, or reordered bundle lists are user-owned and remain untouched. Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. +The `web` and `headless` profiles auto-initialize from shipped templates on first use (`web`: base + web-app; `headless`: base + headless). Any other missing profile fails loud with a hint to run `dsh plugin --profile add `. -Profile boot accepts no positional task. A profile that mounts the one-shot runner row (`headless-runner`) therefore fails loud with the canonical `dsh run --profile ""` command instead of reaching the row's raw required-field error. +### App arguments + +The launcher's flags come first and end at the first token it does not recognize; everything from there on is handed to the booted profile verbatim through `ctx.cmdlineArgs`, where any injected app plugin may parse it ([`dsh-cmdline`](../../../packages/boot/cmdline/README.md)). `dsh --profile web --port 8080` therefore reaches the web app's `--port`, `dsh --profile web --help` prints that app's help and boots nothing, and `dsh --help` (no profile to hand it to) prints the launcher's own. `-V`/`--version` prints the launcher's version when it appears before the app-argument boundary. + +A composition mounts once. An ordinary plugin injects `cmdlineArgs`, parses this app's arguments, and provides what it resolved as a service; each row configured from flags injects that service, and Loader waits for it before evaluating the row's config (`port: !!js ctx.webStartup.port ?? 3080`). A flag therefore beats the value written beside it. This precedence requires the row to retain that expression; a user patch that replaces the whole `config` with literals removes the runtime read. Help and rejected arguments request exit — nonzero for a rejection, 0 for help — without activating rows that depend on the provider's service. A live `cordis.patch.yml` edit re-evaluates expressions against services that are still up, so it cannot reset a served port. + +Launcher flags must come before app arguments, and the launcher's parser consumes one `--`: an app argument that must arrive as a literal `--` needs `-- --`. A first app argument equal to `web` or `plugin` selects that subcommand instead. `ctx.cmdlineArgs.get()` is a shared immutable read: multiple plugins may parse the same snapshot, while a profile with no reader ignores its app arguments. + +The shipped apps own these command lines: + +| Profile | Arguments | +|---|---| +| `web` | `--host`, `--port`, `--dev`, repeatable `--trusted-host` | +| `headless` | the task text, as the positional argument | + +A one-shot task (`dsh --profile headless "run the tests"`) creates one fresh persisted Agent through the core registry, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. An invocation with no task is a usage error from that app. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. Inspect the composed tree without booting it: @@ -21,13 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. - -## One-shot run - -`dsh run [--profile ] [--patch ...] ` joins the task arguments with spaces, rejects a missing or blank task, and defaults `--profile` to `headless`. Repeatable `--patch` overlays occupy the same layer position as profile-boot overlays. A custom selected profile must mount `headless-runner`; otherwise launch fails before boot with a diagnostic naming that missing row. - -The launcher patches the task text into the runner row. After Loader settlement, the runner reads the shared `ctx.agentDefaultModel` default, creates one fresh persisted Agent through `ctx.agents`, submits the task, waits for quiescence, and flushes the Session before deriving the last non-empty assistant text and final `turn/end` reason from its durable interval. It prints the text on stdout and exits 0 for `completed`, else 1. The shipped headless profile mounts no ApiProxy, Host, HTTP server, Web runtime, or browser client; a successful run writes nothing to stderr and opens no listening port. +`--dump-default-config` prints only the bundle layers; `--dump-config` adds the profile's `cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and `--patch` overlays. Both print comments naming the file that supplied each row and every overlay that changed it; `!!js` expressions remain unevaluated, and unmatched patch targets are reported on stderr. A dump never runs app command-line providers, so it shows the composed tree before any app argument is resolved and rejects an invocation that carries app arguments. ## Plugin management @@ -43,12 +52,13 @@ Git-hosted plugins that ship sources build during install through their `prepare ## Web alias -`dsh web` is a hardcoded alias for `--profile web` that additionally accepts the Web flag family. `--host`, `--port`, and repeatable `--trusted-host` values become patches over the composed rows; their owning plugin schemas validate them at boot. `--dev` switches the web-runtime row to development mode and inserts the client-plugin HMR receiver; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. +`dsh web` is a hardcoded alias for `--profile web`; the flags after it belong to the web app, whose ordinary bundle provider parses them. `--host` and `--port` override the composed values of the rows that carry them, repeatable `--trusted-host` contributes invocation authorities through `ctx.webRuntime.trustedHosts` (a deployment expression concatenates its own authorities), and `--dev` switches the web-runtime row to development mode and enables the client-plugin HMR receiver the bundle ships disabled; it expects a separate `pnpm run dev:web` watcher for no-refresh client bundle updates. ```sh dsh web dsh web --patch ./extra.cordis.yml dsh web --dump-config +dsh web --help ``` The production Web runner needs built package and frontend artifacts (`pnpm run build`). It serves `http://127.0.0.1:3080` by default. Binding all interfaces also trusts the machine's discovered LAN IP literals; `--trusted-host` adds named authorities accepted by the `/api` browser-trust fence. @@ -59,24 +69,16 @@ All modes treat the invoking directory as the default workspace root, load appli New sessions default to the `workspace-write` permission preset. Bash and filesystem mutations are restricted to the session workspace and platform temporary roots; reads, network access, and process visibility are not confined. `DSH_PERMISSION_MODE` changes the process fallback. Stored General-settings permissions affect later Web sessions, not an already-open one. -`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. [`config/core-web.cordis.yml`](../config/core-web.cordis.yml) is an optional RL-compatible `--patch` overlay that pins native mode, renders only `DSH_SYSTEM_PROMPT` or `You are a helpful software engineer assistant.` as the system prompt, disables Workspace instructions and every Web runtime prompt contribution, and exposes only persistent `bash` and `str_replace_editor` while retaining the shipped host, browser, workspace, persistence, and permission composition. - -`DSH_SYSTEM_PROMPT` is passed as the system-prompt [`persona`](../../../packages/core/system-prompt/README.md#config): complete `{{…}}` groups use that contract's strict variable interpolation rules and have no literal-brace escape; any set value, including an empty string, is authoritative and an empty value therefore removes the system prompt, while only an unset variable selects the fallback. +`DSH_TOOLS_MODE` selects `native`, `code`, or `both` for the process; another value fails at boot. The shipped `minimal` agent preset keeps that deployment presentation, fixes the complete system prompt to `You are a helpful software engineer assistant.`, and composes only persistent `bash` plus `str_replace_editor`. Select 极简模式 when creating a Web session; every other prompt section and model-facing plugin remains absent from that agent while the shared browser, workspace, persistence, sandbox, and permission host stays in place. ## Shared deployment behavior -The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, repository Plugin support, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. +The base bundle mounts the native DeepSeek adapter, settings and credential providers, stable `web_search`, and session telemetry. Provider credentials resolve from the inherited environment, `$DSH_HOME/.credentials.yaml`, the invoking directory's `.env`, then `$DSH_HOME/.env`; the managed document is never materialized into `process.env`, while both `.env` files are ordinary launch environment layers. Search uses `DEEPSEEK_API_KEY` and accepts `DEEPSEEK_SEARCH_BASE_URL`; `web_fetch` is disabled unless a patch layer inserts a provider and enables it. Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` selects another collector. Any non-empty `DSH_TELEMETRY_DISABLED` disables the telemetry row before boot. The shipped base has no telemetry redaction rule, so exported records can contain message text, tool arguments and results, and workspace paths; the [telemetry Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md) owns that deployment decision. -The empty `repository-plugins` row lets profile patch layers mount prepared immutable repository Plugin generations. See the [repository Plugin contract](../../../packages/self-modification/repository-plugin/README.md#standalone-app-configuration). The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. +Install external plugin bundles through `dsh plugin --profile add `. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox. -## Source launcher +## Source execution -Link the source-running launcher onto PATH: - -```sh -ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh -``` - -It resolves the checkout through its real path and launches `apps/cli/src/bin.ts` with `node --import tsx/esm`. `TSX_TSCONFIG_PATH` is pinned to the checkout root, so workspace package resolution is independent of the invoking directory. `pnpm run dsh` uses the same entry and forwards arguments. The built form is `apps/cli/lib/bin.js` after `pnpm run build`. +From the repository root, use `pnpm dsh `. The `package.json` script runs the complete repository build, launches `apps/cli/src/bin.ts` with `node --import tsx/esm`, and forwards every argument. Build output appears before CLI output. The process inherits the launch environment; set `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository. diff --git a/apps/cli/reference/README.zh.md b/apps/cli/reference/README.zh.md index b9c48c16dd..f6721ec256 100644 --- a/apps/cli/reference/README.zh.md +++ b/apps/cli/reference/README.zh.md @@ -2,17 +2,32 @@ [English](README.md) | 中文 -本参考定义 profile、一次性运行、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 +本参考定义 profile、web 别名、插件管理和配置 dump 命令模式。参数由 [`src/args.ts`](../src/args.ts) 统一解析,[`src/bin.ts`](../src/bin.ts) 只动态导入选中的运行器。 ## Profile 启动 -`dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、按 argv 顺序的各个 `--patch ` overlay,以及启动器 flag patch。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 +`dsh --profile ` 启动位于 `$DSH_HOME/profiles/` 的 profile。生效配置树在空根节点之上按以下顺序逐层组合:profile manifest(元数据清单)的 `dsh.profile.bundles` 列表所列的各个组合包 patch、profile 自身的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml`(各 profile 共享的机器本地偏好,因此优先级高于逐 profile 的层)、以及按 argv 顺序的各个 `--patch ` overlay。后应用的层按行胜出;patch 替换目标行完整的 `config` 值,而不是深度合并各键,并且可以插入新行。配置解析、schema 校验、模块解析或插件启动失败会得到报告并以非零状态退出。收到 SIGINT 或 SIGTERM 时,挂载的根节点会先 dispose(资源释放)再退出。 组合包名称先从 dsh 安装解析,再从 profile 目录解析。因此内置组合包(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`)总是来自与正在运行的 `dsh` 相同的安装;树外组合包来自 profile 由 pnpm 管理的 `node_modules`。任何 patch 行中的裸插件 `name` 通过 profile 目录的 Node 父目录逐级查找解析,该查找可达到持续维护的安装后备目录 `$DSH_HOME/profiles/node_modules`(安装的应用和组合包所依赖的每个包对应一个符号链接,每次启动时修复)。 -`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。加载时,与安装所管理的 headless 元组(base + web-app + headless)完全一致的列表会规范化为随附模板;包含额外项、缺少项或调整过顺序的组合包列表由用户拥有,保持不变。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 +`web` 和 `headless` profile 首次使用时会从随附模板自动初始化(`web`:base + web-app;`headless`:base + headless)。其他缺失的 profile 会显式报错,并提示运行 `dsh plugin --profile add `。 -Profile 启动不接受位置参数任务。因此,挂载了一次性运行器行(`headless-runner`)的 profile 会显式报错,并提示规范命令 `dsh run --profile ""`,而不会触发该行原始的必填字段错误。 +### 应用参数 + +启动器自己的 flag 写在最前面,并在它不认识的第一个 token 处结束;从那里开始的一切都通过 `ctx.cmdlineArgs` 原样交给启动起来的 profile,任何注入它的应用插件都可以解析([`dsh-cmdline`](../../../packages/boot/cmdline/README.md))。因此 `dsh --profile web --port 8080` 到达的是 web 应用的 `--port`,`dsh --profile web --help` 打印的是该应用的 help 且什么也不启动,而 `dsh --help`(没有可以交付的 profile)打印的是启动器自己的 help。`-V`/`--version` 写在应用参数边界之前时会打印启动器的版本。 + +一套组合只挂载一次。普通插件注入 `cmdlineArgs`、解析本应用参数,并把结果作为服务提供出去;由 flag 配置的每一行都会注入该服务,Loader 会等服务激活后再求值该行配置(`port: !!js ctx.webStartup.port ?? 3080`),因此 flag 胜过写在它旁边的值。该优先级要求配置行保留这一表达式;若用户 patch 用字面量替换整份 `config`,运行时读取也会随之消失。help 和被拒绝的参数会请求退出——拒绝时以非零状态,help 时以 0——且不会激活依赖提供方服务的行。在线编辑 `cordis.patch.yml` 会针对仍然在线的服务重新求值表达式,因此不会重置已在服务的端口。 + +启动器的 flag 必须写在应用参数之前,且启动器的解析器会消耗掉一个 `--`:必须以字面量 `--` 送达应用的参数需要写成 `-- --`。如果应用的第一个参数恰好等于 `web` 或 `plugin`,会选择对应的子命令。`ctx.cmdlineArgs.get()` 是共享的不可变读取:多个插件可以解析同一份快照,没有读取方的 profile 则会忽略自己的应用参数。 + +随附的各应用持有这些命令行: + +| Profile | 参数 | +|---|---| +| `web` | `--host`、`--port`、`--dev`、可重复的 `--trusted-host` | +| `headless` | 任务文本,作为位置参数 | + +一次性任务(`dsh --profile headless "run the tests"`)通过核心注册表创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。没有任务的调用是该应用的用法错误。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 可在不启动的情况下检查组合出的配置树: @@ -21,13 +36,7 @@ dsh --profile web --dump-default-config dsh --profile web --patch ./extra.yml --dump-config ``` -`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。 - -## 一次性运行 - -`dsh run [--profile ] [--patch ...] ` 会用空格拼接任务参数,拒绝缺失或空白任务,并让 `--profile` 默认为 `headless`。可重复使用的 `--patch` overlay 与 profile 启动的 overlay 位于同一层。所选的自定义 profile 必须挂载 `headless-runner`;否则启动器会在启动前失败,并在诊断中指明缺少该行。 - -启动器把任务文本 patch 进运行器行。Loader 结算后,运行器读取共享的 `ctx.agentDefaultModel` 默认值,通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),提交任务、等待完全停稳并对 Session 执行 flush,再从其持久化事件区间中推导最后一个非空 assistant 文本与最终 `turn/end` 原因。它在 stdout 打印文本,并在原因为 `completed` 时以 0 退出,否则以 1 退出。随附 headless profile 不挂载 ApiProxy、Host、HTTP 服务器、Web 运行时或浏览器客户端;成功运行不会向 stderr 写入任何内容,也不会打开监听端口。 +`--dump-default-config` 只打印组合包各层;`--dump-config` 额外加上 profile 的 `cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 和 `--patch` overlay。两者都会打印注释,标明每行由哪个文件提供,以及哪些 overlay 修改过它;`!!js` 表达式保持未求值,找不到目标的 patch 会报告到 stderr。dump 从不运行应用命令行提供方,因此它展示的是任何应用参数被解析之前的组合配置树,并拒绝携带应用参数的调用。 ## 插件管理 @@ -43,12 +52,13 @@ Git 托管、随附源码的插件在安装期间通过其 `prepare` 脚本构 ## Web 别名 -`dsh web` 是 `--profile web` 的硬编码别名,并额外接受 Web flag 系列。`--host`、`--port` 和可重复的 `--trusted-host` 值会成为作用在组合行之上的 patch;负责这些值的插件 schema 会在启动时验证它们。`--dev` 把 web-runtime 行切换到开发模式并插入客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 +`dsh web` 是 `--profile web` 的硬编码别名;写在它之后的 flag 属于 web 应用,由组合包中的普通提供方解析。`--host` 和 `--port` 覆盖承载它们的那些行的组合取值,可重复的 `--trusted-host` 通过 `ctx.webRuntime.trustedHosts` 提供本次调用的 authority(部署表达式会拼接自己的 authority),`--dev` 把 web-runtime 行切换到开发模式并启用组合包以禁用状态交付的客户端插件 HMR(热模块替换)接收器;若要无刷新更新客户端 bundle,还需单独运行 `pnpm run dev:web` watcher。 ```sh dsh web dsh web --patch ./extra.cordis.yml dsh web --dump-config +dsh web --help ``` 生产 Web 运行器需要已构建的包和前端产物(`pnpm run build`)。默认服务地址是 `http://127.0.0.1:3080`。绑定所有接口时,还会信任机器自动发现的 LAN IP 字面量;`--trusted-host` 可添加 `/api` 浏览器信任围栏接受的具名 authority。 @@ -59,24 +69,16 @@ dsh web --dump-config 新会话默认使用 `workspace-write` 权限预设。Bash 和文件系统修改仅限于会话 workspace 与平台临时根目录;读取、网络访问和进程可见性不受限制。`DSH_PERMISSION_MODE` 更改进程后备值。General settings 中存储的权限影响后续 Web 会话,不改变已打开的会话。 -`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。[`config/core-web.cordis.yml`](../config/core-web.cordis.yml) 是可选的 RL 兼容 `--patch` overlay:它固定使用 `native` 模式,仅将 `DSH_SYSTEM_PROMPT` 或 `You are a helpful software engineer assistant.` 渲染为系统提示词,禁用 Workspace 指令与所有 Web 运行时提示词贡献,并且在保留随附宿主、浏览器、workspace、持久化和权限组合的同时,仅暴露持久 `bash` 和 `str_replace_editor`。 - -`DSH_SYSTEM_PROMPT` 会传给系统提示词的 [`persona`](../../../packages/core/system-prompt/README.md#config):完整的 `{{…}}` 分组遵循该约定的严格变量插值规则,且无法转义为字面花括号;任何已设置的值(包括空字符串)都具有权威性,因此空值会移除系统提示词,只有未设置该变量时才会选择后备值。 +`DSH_TOOLS_MODE` 为进程选择 `native`、`code` 或 `both`;其他值会导致启动失败。随附的 `minimal` agent preset 会保留该部署的呈现方式,将完整系统提示词固定为 `You are a helpful software engineer assistant.`,并且仅组合持久 `bash` 和 `str_replace_editor`。创建 Web 会话时请选择极简模式;该 agent 不包含任何其他提示词段落或面向模型的插件,而共享的浏览器、workspace、持久化、沙箱与权限宿主保持不变。 ## 共享部署行为 -基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search`、repository Plugin 支持和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 +基础组合包挂载原生 DeepSeek 适配器、settings 与凭据提供方、稳定的 `web_search` 和会话遥测。提供方凭据依次从继承环境、`$DSH_HOME/.credentials.yaml`、调用目录的 `.env` 和 `$DSH_HOME/.env` 解析;受管文档从不物化进 `process.env`,而两个 `.env` 文件都是普通启动环境层。搜索使用 `DEEPSEEK_API_KEY` 并接受 `DEEPSEEK_SEARCH_BASE_URL`;只有 patch 层插入提供方并启用 `web_fetch` 后,该工具才可用。 会话事件默认作为 OTLP/HTTP 日志流式发送。`DSH_TELEMETRY_OTLP_URL` 选择其他 collector。任何非空 `DSH_TELEMETRY_DISABLED` 都会在启动前禁用遥测配置行。随附基础配置没有遥测脱敏规则,因此导出的记录可能包含消息文本、工具参数与结果以及 workspace 路径;该部署决策由[遥测 Agent Note](../../../.agents/notes/implemented/feature/2026-07-31-web-telemetry-default-mount.md)负责。 -空 `repository-plugins` 行让 profile 的 patch 层能够挂载已准备的不可变 repository Plugin generation。参见 [repository Plugin 约定](../../../packages/self-modification/repository-plugin/README.md#standalone-app-configuration)。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。 +通过 `dsh plugin --profile add ` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。 -## 源码启动器 +## 源码执行 -把源码运行启动器链接到 PATH: - -```sh -ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh -``` - -它通过 real path 解析 checkout,并使用 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`。`TSX_TSCONFIG_PATH` 固定到 checkout 根目录,因此 workspace 包解析不依赖调用目录。`pnpm run dsh` 使用同一入口并转发参数。运行 `pnpm run build` 后,构建形式为 `apps/cli/lib/bin.js`。 +请从仓库根目录使用 `pnpm dsh `。`package.json` 中的脚本会完成整个仓库的构建,通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。构建输出会显示在 CLI 输出之前。该进程会继承启动环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,请设置 `NODE_USE_ENV_PROXY=1`。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。 diff --git a/apps/cli/src/args.ts b/apps/cli/src/args.ts index 97f5222398..27d92dcf66 100644 --- a/apps/cli/src/args.ts +++ b/apps/cli/src/args.ts @@ -1,31 +1,30 @@ /** - * Commander adapter for the `dsh` command-line entry. The default command - * boots a named profile (`--profile `), optionally with extra `--patch` - * overlays. `run` owns one-shot task execution, defaulting to the headless - * profile; `web` is a hardcoded alias for `--profile web` that adds the Web - * flag family; `plugin` manages a profile's plugin dependencies by forwarding - * to pnpm. Commander owns help, version, and parse errors. + * Commander adapter for the `dsh` command line. + * + * The launcher parses only what it owns — which profile to boot, which extra + * patch overlays to apply, and the config dumps — and hands **everything after + * its own flags** to the booted tree verbatim, where injected app plugins parse + * their own flag families and print their own `--help` (see + * `@deepseek-ai/dsh-cmdline`). Launcher flags therefore come first: the first + * token this parser does not recognize starts the inner arguments, so + * `dsh --profile tui --resume abc` boots the tui profile with `--resume abc`, + * and `dsh --profile web -h` prints the web app's help, not this one's. + * + * `web` is a hardcoded alias for `--profile web`; `plugin` manages a profile's + * plugin dependencies by forwarding to pnpm. * @module @deepseek-ai/dsh/args */ import { Command, CommanderError } from 'commander' -/** Boot a named profile. */ +/** Boot a named profile and hand it the invocation's inner arguments. */ interface ProfileInvocation { mode: 'profile' profile: string /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ patches: string[] -} - -/** Run one task through a profile mounting the headless runner. */ -interface RunInvocation { - mode: 'run' - profile: string - /** Extra patch-list overlays applied after the profile's own layer, in argv order. */ - patches: string[] - /** Non-blank task text joined from the variadic positional arguments. */ - task: string + /** Everything after the launcher's own flags, verbatim, for injected app plugins. */ + args: string[] } /** Print a composed profile tree and exit without booting. */ @@ -37,21 +36,6 @@ interface DumpConfigInvocation { patches: string[] } -/** - * Browser UI: `dsh web` (alias of `--profile web`). Host and port remain - * unvalidated pass-throughs to the webserver schema; absent values leave the - * shipped web bundle values intact. - */ -interface WebInvocation { - mode: 'web' - patches: string[] - host?: string - port?: number - dev: boolean - /** Extra authorities for the /api browser-trust fence. */ - trustedHosts?: string[] -} - /** Manage a profile's plugins: forward `args` to pnpm inside the profile directory. */ interface PluginInvocation { mode: 'plugin' @@ -61,31 +45,63 @@ interface PluginInvocation { } /** The resolved `dsh` invocation. Help, version, and errors exit inside {@link parseDshArgs}. */ -export type DshInvocation = ProfileInvocation | RunInvocation | DumpConfigInvocation | WebInvocation | PluginInvocation +export type DshInvocation = ProfileInvocation | DumpConfigInvocation | PluginInvocation -/** Raw web-subcommand options straight from Commander. */ -interface WebOptions { +/** Launcher flags shared by the default command and the `web` alias. */ +interface BootOptions { patch?: string[] - host?: string - port?: string - dev?: boolean - trustedHost?: string[] dumpConfig?: boolean dumpDefaultConfig?: boolean } -/** Raw run-subcommand options straight from Commander. */ -interface RunOptions { - profile: string - patch?: string[] -} - /** * Repeatable single-value collector: `--patch a.yml --patch b.yml`. Never - * variadic — a variadic `--patch` would swallow a following positional task. + * variadic — a variadic `--patch` would swallow the inner arguments. */ const collect = (value: string, previous: string[] = []): string[] => [...previous, value] +/** The launcher's own help text; each app prints its own. */ +const HELP_EXAMPLES = ` +Examples: + dsh --profile web boot the web profile (same as: dsh web) + dsh --profile headless "run the tests" answer one task, print the result, and exit + dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay + dsh --profile tui --resume arguments after the launcher flags reach the app + dsh --profile web --help the web app's own flags and help + dsh plugin --profile tui add install a plugin into the tui profile +` + +/** + * Resolve a boot or dump invocation from the launcher flags and the leftover + * inner arguments. + * @param program - the command whose options were parsed (the root, or the `web` alias). + * @param profile - the profile these flags boot. + * @param options - the launcher flags commander collected. + * @param args - the leftover arguments, in argv order. + * @returns the resolved invocation. + */ +function resolveBoot(program: Command, profile: string, options: BootOptions, args: string[]): DshInvocation { + const patches = options.patch ?? [] + if (patches.includes('')) program.error('error: --patch needs a path') + if (options.dumpConfig !== true && options.dumpDefaultConfig !== true) { + return { mode: 'profile', profile, patches, args } + } + if (options.dumpConfig === true && options.dumpDefaultConfig === true) { + program.error('error: --dump-config and --dump-default-config are mutually exclusive') + } + // The dump is boot-free: it never runs app command-line providers, so it + // cannot show what those flags would decide, and printing a tree that differs + // from the same invocation's boot would mislead. + if (args.length > 0) { + program.error(`error: config dumps take no app arguments, got ${args.map(argument => JSON.stringify(argument)).join(' ')}`) + } + const defaultOnly = options.dumpDefaultConfig === true + if (defaultOnly && patches.length > 0) { + program.error('error: --dump-default-config prints the bundle layers and takes no --patch') + } + return { mode: 'dump-config', profile, defaultOnly, patches } +} + /** * Resolve argv into one invocation, or print and exit for help, version, or an * error. @@ -95,121 +111,61 @@ const collect = (value: string, previous: string[] = []): string[] => [...previo */ export function parseDshArgs(argv: readonly string[], version: string): DshInvocation { let resolved: DshInvocation | undefined - const program = new Command() + // Annotated, not inferred: the actions below call back into `program`, and an + // inferred type would be circular through its own chain. + const program: Command = new Command() + program .name('dsh') .version(version, '-V, --version', 'output the version number') .description('dsh: boot a DeepSeek Harness profile — an ordered stack of plugin-bundle patch layers under your own overrides.') - .addHelpText('after', ` -Examples: - dsh --profile web boot the web profile (same as: dsh web) - dsh run "run the tests" answer one task, print the result, and exit - dsh run --profile custom "run the tests" run one task through a custom one-shot profile - dsh --profile tui --patch ./extra.yml boot a custom profile with one extra overlay - dsh plugin --profile tui add install a plugin into the tui profile - dsh web --port 8080 the web alias with its flag family -`) + .addHelpText('after', HELP_EXAMPLES) .exitOverride() + // The launcher's flags come first and end at the first token it does not + // know; everything from there on belongs to the booted app, including + // its -h. `dsh -h` with no profile still prints this help, below. + .helpOption(false) + .allowUnknownOption() + .passThroughOptions() .enablePositionalOptions() + .argument('[args...]', 'arguments for the booted profile\'s app (see: dsh --profile --help)') .option('--profile ', 'the profile under $DSH_HOME/profiles to boot') .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) .option('--dump-config', 'print the composed profile tree and exit') .option('--dump-default-config', 'print the profile tree without its user layer or --patch overlays and exit') - .action((options: { - profile?: string - patch?: string[] - dumpConfig?: boolean - dumpDefaultConfig?: boolean - }) => { - const profile = options.profile ?? program.error('error: --profile is required') - if (profile === '') program.error('error: --profile needs a name') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - if (options.dumpConfig === true || options.dumpDefaultConfig === true) { - if (options.dumpConfig === true && options.dumpDefaultConfig === true) { - program.error('error: --dump-config and --dump-default-config are mutually exclusive') - } - const defaultOnly = options.dumpDefaultConfig === true - if (defaultOnly && patches.length > 0) { - program.error('error: --dump-default-config prints the bundle layers and takes no --patch') - } - resolved = { mode: 'dump-config', profile, defaultOnly, patches } - return + .action((args: string[], options: BootOptions & { profile?: string }) => { + // With the app owning -h, the launcher's own help is what a bare + // `dsh -h` (no profile to hand it to) must print. + if (options.profile === undefined) { + if (args.some(argument => argument === '-h' || argument === '--help')) program.help() + program.error('error: --profile is required') } - resolved = { mode: 'profile', profile, patches } + const profile = options.profile + if (profile === '') program.error('error: --profile needs a name') + resolved = resolveBoot(program, profile, options, args) }) /** Reject parent options supplied before a subcommand. */ const rejectParentOptions = (command: string): void => { - const parent = program.opts<{ - profile?: string - patch?: string[] - dumpConfig?: boolean - dumpDefaultConfig?: boolean - }>() + const parent = program.opts() if (parent.profile !== undefined || parent.patch !== undefined || parent.dumpConfig !== undefined || parent.dumpDefaultConfig !== undefined) { program.error(`error: ${command} takes none of parent --profile, --patch, --dump-config, or --dump-default-config`) } } - const run = program.command('run').description('run one task through a profile mounting the headless runner') - run - .option('--profile ', 'one-shot profile under $DSH_HOME/profiles', 'headless') - .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) - .argument('', 'task text') - .action((task: string[], options: RunOptions) => { - rejectParentOptions('run') - const profile = options.profile - if (profile === '') program.error('error: --profile needs a name') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - const joined = task.join(' ') - if (joined.trim() === '') program.error('error: run needs a non-blank task') - resolved = { mode: 'run', profile, patches, task: joined } - }) - - const web = program.command('web').description('serve the browser UI (alias of --profile web) on the configured host and port') + const web = program.command('web').description('boot the web profile (alias of --profile web); the web app\'s own flags follow') web + .helpOption(false) + .allowUnknownOption() + .passThroughOptions() + .enablePositionalOptions() + .argument('[args...]', 'arguments for the web app (see: dsh web --help)') .option('--patch ', 'extra patch-list overlay applied after the profile layer (repeatable)', collect) - .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') - .option('--port ', 'listen port; pass 0 to let the OS pick a free one') - .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') - .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') .option('--dump-config', 'print the composed web-profile tree (with the user layer and any --patch) and exit') .option('--dump-default-config', 'print the web profile\'s bundle layers (no user layer) and exit') - .action((options: WebOptions) => { + .action((args: string[], options: BootOptions) => { rejectParentOptions('web') - const patches = options.patch ?? [] - if (patches.includes('')) program.error('error: --patch needs a path') - if (options.dumpConfig === true || options.dumpDefaultConfig === true) { - if (options.dumpConfig === true && options.dumpDefaultConfig === true) { - program.error('error: --dump-config and --dump-default-config are mutually exclusive') - } - const defaultOnly = options.dumpDefaultConfig === true - if (defaultOnly && patches.length > 0) { - program.error('error: --dump-default-config prints the bundle layers and takes no --patch') - } - // The dump is boot-free and does not derive flag patches; silently - // dropping them would print a tree that differs from the same - // invocation's boot. - if (options.host !== undefined || options.port !== undefined || options.dev === true - || options.trustedHost !== undefined) { - program.error('error: config dumps take no web flags (--host/--port/--dev/--trusted-host)') - } - resolved = { mode: 'dump-config', profile: 'web', defaultOnly, patches } - return - } - if (options.port !== undefined && !/^\d+$/.test(options.port)) { - program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) - } - resolved = { - mode: 'web', - patches, - ...options.host !== undefined && { host: options.host }, - ...options.port !== undefined && { port: Number(options.port) }, - dev: options.dev === true, - ...options.trustedHost !== undefined && { trustedHosts: options.trustedHost }, - } + resolved = resolveBoot(web, 'web', options, args) }) const plugin = program.command('plugin').description('manage a profile\'s plugins by forwarding the remaining arguments to pnpm in the profile directory') diff --git a/apps/cli/src/bin.ts b/apps/cli/src/bin.ts index b332a64615..9aa44f8b22 100644 --- a/apps/cli/src/bin.ts +++ b/apps/cli/src/bin.ts @@ -33,24 +33,10 @@ switch (invocation.mode) { environment: loadLayeredEnv('dsh'), profile: invocation.profile, patchFiles: invocation.patches, + args: invocation.args, }) break } - case 'run': { - const { runProfile } = await import('./profile-boot.ts') - await runProfile({ - environment: loadLayeredEnv('dsh'), - profile: invocation.profile, - patchFiles: invocation.patches, - task: invocation.task, - }) - break - } - case 'web': { - const { runWeb } = await import('./web.ts') - await runWeb(invocation, loadLayeredEnv('dsh')) - break - } case 'plugin': { const { runPlugin } = await import('./plugin.ts') process.exit(runPlugin(invocation.profile, invocation.args)) diff --git a/apps/cli/src/profile-boot.ts b/apps/cli/src/profile-boot.ts index e4a719379e..f3c356f199 100644 --- a/apps/cli/src/profile-boot.ts +++ b/apps/cli/src/profile-boot.ts @@ -1,18 +1,22 @@ /** * Shared profile boot for every `dsh` surface: resolve the profile, stack its - * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's own - * `cordis.patch.yml`, `--patch` overlays, flag-derived patches, the telemetry - * switch), mount the tree over the profile's empty root config, keep the - * profile patch layer live, and wire fail-loud plus bounded shutdown. + * patch layers (bundle layers in `dsh.profile.bundles` order, the profile's + * own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the + * tree over the profile's empty root config, keep the profile patch layer + * live, and wire fail-loud plus bounded shutdown. + * + * App flags are not the launcher's business: the invocation's inner arguments + * are provided to the tree through `ctx.cmdlineArgs`, where any injected app + * plugin may read the same immutable snapshot. * @module @deepseek-ai/dsh/profile-boot */ import { writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' -import { FiberState, type Context } from 'cordis' -import type { PatchOptions } from '@cordisjs/plugin-include' -import { dshHomePath } from '@deepseek-ai/dsh-paths' +import { FiberState, type Context } from '@deepseek-ai/cordis' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' import { boot, composeEntries, @@ -25,7 +29,7 @@ import { watchUserPatches, type Profile, } from '@deepseek-ai/dsh-app-boot' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' +import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' /** Shipped agent-preset root: beside this app's own config, in both source and built layouts. */ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', import.meta.url)) @@ -33,6 +37,7 @@ const SHIPPED_PRESET_ROOT = fileURLToPath(new URL('../config/agent-presets/', im /** Harness-home directory holding locally authored agent presets. */ const USER_PRESET_DIR = '.agent-presets' import { DSH_ENVIRONMENT_KEY, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import type { HeadlessIo } from '@deepseek-ai/dsh-headless' import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts' import { resolveWindowsShellLayer } from './windows-shell.ts' @@ -55,7 +60,7 @@ export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.me /** The session-telemetry row id the DSH_TELEMETRY_DISABLED switch targets. */ const TELEMETRY_ROW_ID = 'telemetry-otel' -/** The one-shot runner row a `dsh run` task requires and configures. */ +/** The one-shot runner row: its presence means this composition exits by itself. */ const HEADLESS_ROW_ID = 'headless-runner' /** The empty root entry list every profile tree patches over. */ @@ -104,9 +109,6 @@ export function prepareProfile(name: string, userLayer = true): Profile { return profile } -/** Read-only row index of a profile composition before launcher flag patches. */ -export type ProfileRows = ReadonlyMap - /** One profile's patch layers (application order) and the row index of its pre-flag composition. */ interface ComposedProfile { profile: Profile @@ -116,14 +118,13 @@ interface ComposedProfile { windowsShellPatches: PatchOptions[] /** The home-level user layer (`$DSH_HOME/cordis.patch.yml`), applied after the profile's own. */ homePatches: PatchOptions[] - /** Layers above the user layers on a live reload: --patch overlays, flag patches, the telemetry switch. */ - overlayAndFlags: PatchOptions[] + /** Layers above the user layers on a live reload: `--patch` overlays and the telemetry switch. */ + overlays: PatchOptions[] /** - * id → row of the pre-flag composition (bundles + user layers + overlays), - * for flag merges and row checks. Flag patches must not insert rows the - * launcher consults here (they only override values and insert dev glue). + * id → row of the composed tree (bundles + user layers + overlays), for the + * launcher's own row checks. */ - rows: ProfileRows + rows: ReadonlyMap } /** The full patch stack of one composed profile, in application order. */ @@ -133,7 +134,7 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { ...composed.windowsShellPatches, ...composed.profile.patches, ...composed.homePatches, - ...composed.overlayAndFlags, + ...composed.overlays, ] } @@ -143,36 +144,28 @@ function allPatches(composed: ComposedProfile): PatchOptions[] { * is Windows), the profile's user layer, the home-level user layer * (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply to * every profile, so it outranks the per-profile layer), `--patch` overlays, - * then flag patches derived from the composed rows, then the telemetry - * switch. + * then the telemetry switch. * @param name - the profile name. * @param patchFiles - `--patch` overlay paths, in argv order. - * @param deriveFlagPatches - launcher hook turning composed rows into flag patches. * @returns the profile, its patch layers, and the composed row index. */ function composeProfile( name: string, patchFiles: readonly string[], - deriveFlagPatches: (rows: ComposedProfile['rows']) => PatchOptions[] = () => [], ): ComposedProfile { const profile = prepareProfile(name) const homePatches = loadOptionalPatches(NAME, homePatchPath()) ?? [] const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file))) const bundlePatches = profile.layers.flatMap(layer => layer.patches) const windowsShellPatches = resolveWindowsShellLayer(process.platform, profile.layers, NAME)?.patches ?? [] - const rows = new Map() + const rows = new Map() for (const row of composeEntries([bundlePatches, windowsShellPatches, profile.patches, homePatches, overlays])) { if (typeof row.id === 'string') rows.set(row.id, row) } - const overlayAndFlags = [...overlays, ...deriveFlagPatches(rows)] - // The agent-preset roots are an assembly fact of every dsh launcher, not a - // patch author's choice: the shipped set sits beside this app's config and - // the user's own under the Harness home. Resolved per boot ($DSH_HOME may - // differ per run) and only patched when the composed tree actually mounts - // the roster — a one-shot `dsh run` composes agents from the same roster - // `dsh web` offers. + const composedOverlays = [...overlays] + // Preset roots belong to every dsh composition that mounts the roster. if (rows.has('agent-presets')) { - overlayAndFlags.push({ + composedOverlays.push({ id: 'agent-presets', config: { ...(rows.get('agent-presets')?.config ?? {}) as Record, @@ -184,24 +177,20 @@ function composeProfile( }) } const telemetryPatch = resolveTelemetryPatch(process.env.DSH_TELEMETRY_DISABLED, rows.has(TELEMETRY_ROW_ID)) - if (telemetryPatch !== undefined) overlayAndFlags.push(telemetryPatch) - return { profile, bundlePatches, windowsShellPatches, homePatches, overlayAndFlags, rows } + if (telemetryPatch !== undefined) composedOverlays.push(telemetryPatch) + return { profile, bundlePatches, windowsShellPatches, homePatches, overlays: composedOverlays, rows } } /** Options for {@link runProfile}. */ export interface RunProfileOptions { + /** This run's frozen environment snapshot, provided before any entry mounts. */ + environment: EnvironmentSnapshot /** The profile name to boot. */ profile: string /** `--patch` overlay paths, in argv order. */ patchFiles: readonly string[] - /** Launcher hook turning the pre-flag composed rows into flag patches (the web alias's flag family). */ - deriveFlagPatches?: (rows: ProfileRows) => PatchOptions[] - /** `dsh run` task text; requires the composition to mount the headless runner row. */ - task?: string - /** Surface setup registered after Loader installation and before any config-tree entry mounts. */ - prepare?: (ctx: Context, rows: ProfileRows) => Promise | void - /** This run's frozen environment snapshot, provided to the tree before any entry mounts. */ - environment: EnvironmentSnapshot + /** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */ + args: readonly string[] } /** Re-throw setup failures unless this invocation's signal already owns shutdown. */ @@ -211,29 +200,16 @@ function suppressSignalShutdownError(signal: AbortSignal, error: unknown): void /** * Boot one profile invocation end to end and leave process lifetime to the - * mounted plugins (or to the one-shot runner when `task` is present). - * @param options - profile name, overlays, flag patches, and the optional task. + * mounted plugins (or to a one-shot runner the composition mounts). + * @param options - environment snapshot, profile name, overlays, and the booted app's own arguments. * @returns the settled root context and the shutdown controller. */ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> { - const composed = composeProfile(options.profile, options.patchFiles, options.deriveFlagPatches) - if (options.task !== undefined) { - if (!composed.rows.has(HEADLESS_ROW_ID)) { - throw new Error( - `dsh: profile ${JSON.stringify(options.profile)} takes no task — its composition mounts no "${HEADLESS_ROW_ID}" row ` - + '(the headless profile does)', - ) - } - composed.overlayAndFlags.push({ id: HEADLESS_ROW_ID, config: { task: options.task } }) - } else if (composed.rows.has(HEADLESS_ROW_ID)) { - // The inverse misuse: a one-shot composition booted without its task - // would otherwise die in the runner row's schema with a raw "required" - // error naming no fix. - throw new Error( - `dsh: profile ${JSON.stringify(options.profile)} mounts the one-shot runner and needs a task: ` - + `dsh run --profile ${options.profile} ""`, - ) - } + const composed = composeProfile(options.profile, options.patchFiles) + // A one-shot composition ends by itself, which changes what a signal means + // and makes watching the user's patch layer pointless. + const headlessRow = composed.rows.get(HEADLESS_ROW_ID) + const oneShot = headlessRow !== undefined && headlessRow.disabled !== true const app: { current?: Context } = {} const shutdown = createProcessShutdown(async () => { await app.current?.fiber.dispose() }) @@ -243,9 +219,8 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con shutdown.interrupt(code) } // Signals own teardown throughout the startup window, not only after boot() - // settles: an inserted entry point can publish readiness before sibling rows - // finish mounting. - process.on('SIGTERM', () => { interrupt(options.task === undefined ? 0 : 143) }) + // settles: an inserted provider can publish before sibling rows finish mounting. + process.on('SIGTERM', () => { interrupt(oneShot ? 143 : 0) }) process.on('SIGINT', () => { interrupt(130) }) installFailLoud(NAME, process, async () => { await app.current?.fiber.dispose() @@ -253,7 +228,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME) // Recomposition for the live user layers: bundle layers below, overlays - // and flag patches above, so a user edit can never displace them. BOTH + // above, so a user edit can never displace them. Parsed app arguments are + // not in here at all — they live in app-provided services that survive a + // recomposition. BOTH // user files are re-read per generation (the HMR watcher hands us only the // changed file's patches, which one of the reads duplicates — fresh reads // keep the two watchers from stitching in each other's stale copy). @@ -267,19 +244,25 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con ...composed.windowsShellPatches, ...loadOptionalPatches(NAME, composed.profile.patchPath) ?? [], ...loadOptionalPatches(NAME, homePatchPath()) ?? [], - ...composed.overlayAndFlags, + ...composed.overlays, ]) // One-shot runs exit through the runner; watching would only hold the // process open after its exit request. - const watchProfilePatch = options.task === undefined + const watchProfilePatch = !oneShot // Cloned for the same insert-aliasing reason as composeLive: the boot // application must not mutate the objects later reloads recompose from. - const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), async (hostCtx) => { + const ctx = await boot(NAME, rootConfig, structuredClone(allPatches(composed)), (hostCtx) => { app.current = hostCtx - // Before any config-tree entry mounts, so a plugin that resolves a - // user-facing value at construction already sees this run's layers. + // Before any config-tree entry mounts, so plugins resolve all launch-time + // environment values from the same immutable provenance snapshot. hostCtx.provide(DSH_ENVIRONMENT_KEY, options.environment) - if (options.task !== undefined) { + // The command line and bounded exit request are launcher facts available + // to every app plugin that injects the argument snapshot. + provideCmdline(hostCtx, { + args: options.args, + exit: code => void shutdown.shutdown(code), + }) + if (oneShot) { const io: HeadlessIo = { stdout: process.stdout, stderr: process.stderr, @@ -287,11 +270,10 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con } hostCtx.provide('headlessIo', io) } - await options.prepare?.(hostCtx, composed.rows) }) app.current = ctx - // A surface can dispose the whole tree while startup or this post-boot - // watcher setup is still in flight. Loader presence and fiber state own + // A surface can dispose the whole tree while boot or this post-boot watcher + // setup is still in flight. Loader presence and fiber state own // liveness; the local signal fact distinguishes that expected exit race // from a real HMR error. if (watchProfilePatch @@ -308,9 +290,9 @@ export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Con // bare custom profile may not mount either. if (ctx.get('hmr') === undefined) { if (ctx.get('timer') === undefined) { - await ctx.loader.create({ name: '@cordisjs/plugin-timer' }) + await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-timer' }) } - await ctx.loader.create({ name: '@cordisjs/plugin-hmr', config: { root: [] } }) + await ctx.loader.create({ name: '@deepseek-ai/cordis-plugin-hmr', config: { root: [] } }) } await watchUserPatches(ctx, { binName: NAME, diff --git a/apps/cli/src/web.ts b/apps/cli/src/web.ts deleted file mode 100644 index e438c7da7c..0000000000 --- a/apps/cli/src/web.ts +++ /dev/null @@ -1,144 +0,0 @@ -/** - * `dsh web` — the browser-surface alias over the profile boot: `--profile web` - * plus the Web flag family (`--host/--port/--dev/--trusted-host`), each flag - * becoming a patch over the composed profile - * tree. All web runtime glue (dist serving, prompt section, URL line) lives - * in the `@deepseek-ai/dsh-web-app` bundle; this launcher only derives - * flag patches and the LAN-trust snapshot. - * @module @deepseek-ai/dsh/web - */ - -import { networkInterfaces } from 'node:os' -import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' -import type { PatchOptions } from '@cordisjs/plugin-include' -import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' -import type { EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' -import { runProfile, type ProfileRows } from './profile-boot.ts' - -const SOURCE_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) - -/** The webserver schema's all-interfaces bind literal: gates LAN-authority derivation. */ -const ALL_INTERFACES_HOST = '0.0.0.0' - -/** - * Non-internal IPv4 interface addresses of this machine — the IP-literal - * authorities an all-interfaces bind is reachable by on the LAN. - * @returns the addresses in interface order (possibly empty). - */ -function lanIPv4Addresses(): string[] { - return Object.values(networkInterfaces()).flat() - .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) - .map(iface => iface.address) -} - -/** - * One LAN-trust resolution for one invocation, sampled exactly once: the - * machine's LAN IP literals when the effective bind is all-interfaces, and - * the `trustedHosts` value built from them plus the explicit extras. The - * single sample is deliberate — display must advertise only addresses the - * fence was configured with, so the web-app row receives this same snapshot. - * Derived entries are port-less IP literals: DNS rebinding needs an - * attacker-controlled name, so an IP-literal Host is safe on any port, and - * the bound port may be OS-assigned, unknowable pre-boot. - * @param bindHost - the effective webserver bind host (CLI flag, else the composed row value). - * @param extra - `--trusted-host` values, in argv order. - * @returns the sampled LAN addresses and the connection row's `trustedHosts` value (each possibly empty). - */ -export function resolveLanTrust( - bindHost: string | undefined, - extra: readonly string[], -): { lanAddresses: string[]; trustedHosts: string[] } { - const lanAddresses = bindHost === ALL_INTERFACES_HOST ? lanIPv4Addresses() : [] - return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } -} - -/** The `dsh web` flag family, already parsed by the argument adapter. */ -export interface WebFlags { - patches: string[] - host?: string - port?: number - dev: boolean - trustedHosts?: string[] -} - -/** - * Derive the web alias's flag patches over an already-composed profile tree. - * Patches replace a row's whole config, so each patched row's composed values - * are re-read and merged under the overrides. - * @param rows - the composed row index from {@link composeProfile}. - * @param flags - the parsed flag family. - * @returns the flag patch list, in application order. - */ -function deriveWebFlagPatches( - rows: ProfileRows, - flags: WebFlags, -): PatchOptions[] { - const overrides = new Map>() - const put = (entryId: string, key: string, value: unknown): void => { - const bag = overrides.get(entryId) ?? {} - bag[key] = value - overrides.set(entryId, bag) - } - if (flags.host !== undefined) put('webserver', 'host', flags.host) - if (flags.port !== undefined) put('webserver', 'port', flags.port) - const composedHost = (rows.get('webserver')?.config as { host?: string } | undefined)?.host - const { lanAddresses, trustedHosts } = resolveLanTrust(flags.host ?? composedHost, flags.trustedHosts ?? []) - if (trustedHosts.length > 0) { - // Additive over the composed value: a cordis.patch.yml-configured fence - // authority must survive the derived LAN literals and flag extras — a - // silent drop of security-relevant fence configuration. - const composedTrusted = (rows.get('connection')?.config as { trustedHosts?: string[] } | undefined)?.trustedHosts ?? [] - put('connection', 'trustedHosts', [...composedTrusted, ...trustedHosts]) - } - // mode and lanAddresses are launcher-derived on every boot (--dev also - // inserts the client-hmr row), never pass-throughs of composed values. - put('web-runtime', 'mode', flags.dev ? 'development' : 'production') - put('web-runtime', 'lanAddresses', lanAddresses) - // The agent-preset roots are patched by the shared profile boot: they are - // an assembly fact of every dsh launcher, and `dsh run` composes agents - // from the same roster this alias offers. - const patches = [...overrides.entries()].map(([id, bag]): PatchOptions => { - const composed = rows.get(id) - if (composed === undefined) throw new Error(`dsh: patch target row "${id}" not found in the web profile composition`) - return { id, config: { ...(composed.config ?? {}) as Record, ...bag } } - }) - if (flags.dev) patches.push({ insert: [{ id: 'client-hmr', name: '@deepseek-ai/dsh-client-hmr' }] }) - return patches -} - -/** - * Whether the composed Web runtime keeps its model- and shell-visible surface - * context. The bundle schema defaults the field to true, so only an explicit - * false suppresses both the bundle contributions and the launcher-owned - * source-checkout section. - * @param rows - the composed Web profile rows before launcher flag patches. - * @returns true unless the web-runtime row explicitly disables surface context. - */ -export function webSurfaceContextEnabled(rows: ProfileRows): boolean { - return (rows.get('web-runtime')?.config as { surfaceContext?: boolean } | undefined)?.surfaceContext !== false -} - -/** - * Serve the browser UI from the web profile. Host/port flags are passed - * through only when given (absent, the composed profile values - * stand); `web-runtime.mode` and `lanAddresses` are launcher-derived on - * every boot. The URL line is printed by the web-app bundle's runtime row - * after Loader settlement. - * @param flags - the parsed `dsh web` flag family. - * @param environment - this run's frozen environment snapshot. - */ -export async function runWeb(flags: WebFlags, environment: EnvironmentSnapshot): Promise { - await runProfile({ - environment, - profile: 'web', - patchFiles: flags.patches, - deriveFlagPatches: rows => deriveWebFlagPatches(rows, flags), - prepare: (ctx: Context, rows: ProfileRows) => { - if (!webSurfaceContextEnabled(rows)) return - ctx.inject(['systemPrompt'], (promptCtx) => { - addHarnessSourceSection(promptCtx, SOURCE_ROOT) - }) - }, - }) -} diff --git a/apps/cli/src/windows-shell.ts b/apps/cli/src/windows-shell.ts index fbb3d13194..1a9ca719f8 100644 --- a/apps/cli/src/windows-shell.ts +++ b/apps/cli/src/windows-shell.ts @@ -11,7 +11,7 @@ */ import { join } from 'node:path' -import type { PatchOptions } from '@cordisjs/plugin-include' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { loadOverlayPatches, type ProfileLayer } from '@deepseek-ai/dsh-app-boot' /** The base bundle whose package carries the Windows shell patch. */ diff --git a/apps/cli/tests/args.spec.ts b/apps/cli/tests/args.spec.ts index 603d8e2dd5..89a16921b2 100644 --- a/apps/cli/tests/args.spec.ts +++ b/apps/cli/tests/args.spec.ts @@ -21,22 +21,28 @@ function exitCode(argv: string[]): number { afterEach(() => { vi.restoreAllMocks() }) describe('parseDshArgs', () => { - it('routes profile boots, one-shot runs, and the web alias', () => { - expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [] }) + it('routes profile boots and the web alias, handing the rest to the app', () => { + expect(parse(['--profile', 'tui'])).toEqual({ mode: 'profile', profile: 'tui', patches: [], args: [] }) expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--patch', 'b.yml'])) - .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'] }) - expect(parse(['run', 'run', 'the', 'tests'])) - .toEqual({ mode: 'run', profile: 'headless', patches: [], task: 'run the tests' }) - expect(parse(['run', '--profile', 'custom', '--patch', 'a.yml', '--patch', 'b.yml', 'run', 'the', 'tests'])) - .toEqual({ mode: 'run', profile: 'custom', patches: ['a.yml', 'b.yml'], task: 'run the tests' }) - expect(parse(['run', '--', '--profile', 'is', 'task', 'text'])) - .toEqual({ mode: 'run', profile: 'headless', patches: [], task: '--profile is task text' }) - expect(parse(['web'])).toEqual({ mode: 'web', dev: false, patches: [] }) - expect(parse(['web', '--patch', 'web.yml'])).toEqual({ mode: 'web', dev: false, patches: ['web.yml'] }) + .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml', 'b.yml'], args: [] }) + expect(parse(['web'])).toEqual({ mode: 'profile', profile: 'web', patches: [], args: [] }) + expect(parse(['web', '--patch', 'web.yml'])) + .toEqual({ mode: 'profile', profile: 'web', patches: ['web.yml'], args: [] }) + }) + + it('ends the launcher flags at the first token it does not own', () => { + // App flags, including its -h, and positionals reach the app verbatim. + expect(parse(['--profile', 'tui', '--resume', 'abc'])) + .toEqual({ mode: 'profile', profile: 'tui', patches: [], args: ['--resume', 'abc'] }) + expect(parse(['--profile', 'web', '-h'])) + .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['-h'] }) expect(parse(['web', '--host', '0.0.0.0', '--port', '8080', '--dev'])) - .toEqual({ mode: 'web', host: '0.0.0.0', port: 8080, dev: true, patches: [] }) - expect(parse(['web', '--trusted-host', 'harness.internal:3080', 'lab.internal', '--trusted-host', '10.0.0.9'])) - .toEqual({ mode: 'web', dev: false, patches: [], trustedHosts: ['harness.internal:3080', 'lab.internal', '10.0.0.9'] }) + .toEqual({ mode: 'profile', profile: 'web', patches: [], args: ['--host', '0.0.0.0', '--port', '8080', '--dev'] }) + expect(parse(['--profile', 'headless', 'run', 'the', 'tests'])) + .toEqual({ mode: 'profile', profile: 'headless', patches: [], args: ['run', 'the', 'tests'] }) + // Launcher flags placed after that boundary belong to the app too. + expect(parse(['--profile', 'tui', '--patch', 'a.yml', '--resume', 'b', '--patch', 'late.yml'])) + .toEqual({ mode: 'profile', profile: 'tui', patches: ['a.yml'], args: ['--resume', 'b', '--patch', 'late.yml'] }) }) it('routes the plugin pnpm forwarder', () => { @@ -44,8 +50,8 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'plugin', profile: 'tui', args: ['add', 'turtle-ui'] }) expect(parse(['plugin', '--profile', 'tui', 'remove', 'turtle-ui'])) .toEqual({ mode: 'plugin', profile: 'tui', args: ['remove', 'turtle-ui'] }) - expect(parse(['plugin', '--profile', 'tui', 'why', 'cordis'])) - .toEqual({ mode: 'plugin', profile: 'tui', args: ['why', 'cordis'] }) + expect(parse(['plugin', '--profile', 'tui', 'why', '@deepseek-ai/cordis'])) + .toEqual({ mode: 'plugin', profile: 'tui', args: ['why', '@deepseek-ai/cordis'] }) // Unknown pnpm flags forward verbatim. expect(parse(['plugin', '--profile', 'tui', 'add', '--save-dev', 'x'])) .toEqual({ mode: 'plugin', profile: 'tui', args: ['add', '--save-dev', 'x'] }) @@ -64,18 +70,12 @@ describe('parseDshArgs', () => { .toEqual({ mode: 'dump-config', profile: 'web', defaultOnly: true, patches: [] }) }) - it('rejects missing profile, flags outside the current grammar, and contradictory inputs', () => { + it('rejects missing profile, removed flags, and contradictory inputs', () => { expect(exitCode([])).toBe(1) - expect(exitCode(['tui'])).toBe(1) // a bare word is a task without --profile - expect(exitCode(['--config', 'c.yml'])).toBe(1) // outside the current grammar - expect(exitCode(['-p', 'task'])).toBe(1) // outside the current grammar - expect(exitCode(['--profile', 'headless', 'task'])).toBe(1) // tasks belong to `run` - expect(exitCode(['run'])).toBe(1) - expect(exitCode(['run', ''])).toBe(1) - expect(exitCode(['run', '--profile', '', 'task'])).toBe(1) - expect(exitCode(['run', '--patch=', 'task'])).toBe(1) - expect(exitCode(['--profile', 'headless', 'run', 'task'])).toBe(1) - expect(exitCode(['--patch', 'parent.yml', 'run', 'task'])).toBe(1) + expect(exitCode(['tui'])).toBe(1) // an app argument without --profile has no app to reach + expect(exitCode(['--config', 'c.yml'])).toBe(1) // removed + expect(exitCode(['-p', 'task'])).toBe(1) // removed + expect(exitCode(['run', 'task'])).toBe(1) // app-owned task replaced the launcher subcommand expect(exitCode(['--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', '--patch='])).toBe(1) expect(exitCode(['--dump-config'])).toBe(1) @@ -87,21 +87,20 @@ describe('parseDshArgs', () => { expect(exitCode(['web', '--dump-config', '--dump-default-config'])).toBe(1) expect(exitCode(['web', '--dump-default-config', '--patch', 'w.yml'])).toBe(1) expect(exitCode(['web', '--patch='])).toBe(1) - // Boot-free dumps derive no flag patches; silently dropping the flags - // would print a tree that differs from the same invocation's boot. + // A dump never runs app command-line providers, so it cannot show what + // those flags would decide; printing a tree that differs from the same + // invocation's boot would mislead. expect(exitCode(['web', '--dump-config', '--port', '8080'])).toBe(1) - expect(exitCode(['web', '--dump-config', '--dev'])).toBe(1) - // A non-numeric port fails at the flag, not deep in the webserver schema. - expect(exitCode(['web', '--port', 'abc'])).toBe(1) + expect(exitCode(['--profile', 'web', '--dump-config', '-h'])).toBe(1) expect(exitCode(['plugin', 'add', 'x'])).toBe(1) // --profile required expect(exitCode(['plugin', '--profile', 'tui'])).toBe(1) // nothing to forward expect(exitCode(['plugin', '--profile', ''])).toBe(1) expect(exitCode(['--profile', 'x', 'plugin', 'add', 'y'])).toBe(1) }) - it('exits 0 for help and version', () => { + it('keeps its own help for an invocation with no app to hand it to', () => { expect(exitCode(['--help'])).toBe(0) - expect(exitCode(['run', '--help'])).toBe(0) + expect(exitCode(['-h'])).toBe(0) expect(exitCode(['--version'])).toBe(0) }) }) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index b81f837cf8..81acefadc5 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -8,8 +8,10 @@ import { afterEach, beforeEach, describe, expect, it } from 'vitest' /** Published-entry acceptance for argument errors, profile lifecycle, and boot-free config dumps. */ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) +// The release version, including a prerelease such as 0.0.1-rc.1: `--version` +// prints what this manifest carries, so no test may pin it to a literal. +const cliVersion = (JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf8')) as { version: string }).version const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -const coreWebOverlay = fileURLToPath(new URL('../config/core-web.cordis.yml', import.meta.url)) const invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url)) async function runBuiltBin( @@ -129,8 +131,8 @@ function createProfileLifecycleFixture(): ProfileLifecycleFixture { return { home, ready, settled, disposed, interrupt } } -function startProfileLifecycle(fixture: ProfileLifecycleFixture) { - return execa(process.execPath, [dshBin, '--profile', 'lifecycle'], { +function startProfileLifecycle(fixture: ProfileLifecycleFixture, args: readonly string[] = []) { + return execa(process.execPath, [dshBin, '--profile', 'lifecycle', ...args], { cwd: fixture.home, input: '', reject: false, @@ -145,8 +147,8 @@ function startProfileLifecycle(fixture: ProfileLifecycleFixture) { } function requestProfileShutdown( - child: ReturnType, - fixture: ProfileLifecycleFixture, + child: Pick, 'kill'>, + fixture: Pick, ): void { if (process.platform === 'win32') { writeFileSync(fixture.interrupt, 'interrupt') @@ -194,8 +196,121 @@ function createEnvironmentProbeProfile(home: string, project: string): void { ].join('\n')) } +interface StartupFixture { + home: string + ready: string + echo: string + interrupt: string + /** An always-running row's echo, used to observe that a user patch reload landed. */ + witness: string +} + +/** + * A custom profile whose ordinary provider plugin injects `cmdlineArgs`, plus + * a row that reads its app-owned service through a `!!js` config expression. + * Both plugin modules resolve + * `@deepseek-ai/dsh-cmdline` and `commander` through the profile module + * fallback, exactly as an installed out-of-tree bundle does. + */ +function createStartupFixture(): StartupFixture { + const home = mkdtempSync(join(tmpdir(), 'dsh-profile-startup-')) + const profileDir = join(home, 'profiles', 'startup') + // Written straight into the installed location: a row module resolves its + // own imports from where it is installed, and only inside the profile does + // Node's parent walk reach the installation fallback these plugins need. + const bundleDir = join(profileDir, 'node_modules', 'dsh-startup-bundle') + mkdirSync(bundleDir, { recursive: true }) + writeFileSync(join(bundleDir, 'startup.mjs'), [ + "import { Command } from 'commander'", + "import { parseCmdline } from '@deepseek-ai/dsh-cmdline'", + "export const name = 'fixture-startup'", + "export const inject = ['cmdlineArgs']", + 'export function apply(ctx) {', + " const program = new Command().name('fixture').option('--generation ', 'echoed generation')", + ' const values = parseCmdline(ctx, program, parsed => ({ generation: parsed.opts().generation }))', + ' if (values !== undefined) ctx.provide(\'fixtureStartup\', values)', + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'waiting.mjs'), [ + "import { existsSync, writeFileSync } from 'node:fs'", + "import { join } from 'node:path'", + "export const name = 'startup-fixture'", + 'export function apply(ctx, config = {}) {', + ' let interrupted = false', + ' const heartbeat = setInterval(() => {', + ' if (interrupted || !existsSync(process.env.RAW_INTERRUPT_FILE)) return', + ' interrupted = true', + " process.emit('SIGTERM')", + ' }, 20)', + " writeFileSync(join(process.env.DSH_HOME, 'config-echo'), String(config.generation ?? 'bundle-default'))", + " writeFileSync(process.env.RAW_READY_FILE, 'ready')", + ' ctx.effect(() => () => { clearInterval(heartbeat) })', + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'witness.mjs'), [ + "import { writeFileSync } from 'node:fs'", + "import { join } from 'node:path'", + "export const name = 'reload-witness'", + 'export function apply(ctx, config = {}) {', + " writeFileSync(join(process.env.DSH_HOME, 'witness'), String(config.generation ?? 'bundle-default'))", + '}', + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'cordis.patch.yml'), [ + '- insert:', + ' - id: startup-fixture', + ` name: ${pathToFileURL(join(bundleDir, 'waiting.mjs')).href}`, + ' inject: [fixtureStartup]', + ' config:', + // Lazy interpolation runs only after the provider's service is injected. + " generation: !!js ctx.fixtureStartup.generation ?? 'bundle-default'", + ' - id: fixture-startup', + ` name: ${pathToFileURL(join(bundleDir, 'startup.mjs')).href}`, + ' - id: reload-witness', + ` name: ${pathToFileURL(join(bundleDir, 'witness.mjs')).href}`, + '', + ].join('\n')) + writeFileSync(join(bundleDir, 'package.json'), JSON.stringify({ + name: 'dsh-startup-bundle', + version: '0.0.0', + type: 'module', + dsh: { bundle: { patch: './cordis.patch.yml' } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'package.json'), JSON.stringify({ + name: 'dsh-profile-startup', + private: true, + dependencies: {}, + dsh: { profile: { bundles: ['dsh-startup-bundle'] } }, + }, undefined, 2)) + writeFileSync(join(profileDir, 'cordis.patch.yml'), '[]\n') + return { + home, + ready: join(home, 'ready'), + echo: join(home, 'config-echo'), + interrupt: join(home, 'interrupt'), + witness: join(home, 'witness'), + } +} + +function startStartupProfile(fixture: StartupFixture, args: readonly string[]) { + return execa(process.execPath, [dshBin, '--profile', 'startup', ...args], { + cwd: fixture.home, + input: '', + reject: false, + timeout: 25_000, + killSignal: 'SIGKILL', + env: { + DSH_HOME: fixture.home, + RAW_READY_FILE: fixture.ready, + RAW_INTERRUPT_FILE: fixture.interrupt, + }, + }) +} + describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', () => { - it('requires --profile and rejects inputs outside the current grammar', async () => { + it('requires --profile and rejects removed commands', async () => { const bare = await runBuiltBin() expect(bare.code).toBe(1) expect(bare.stdout).toBe('') @@ -203,46 +318,63 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', const help = await runBuiltBin(['--help']) expect(help.code).toBe(0) expect(help.stdout).toContain('dsh --profile web') - expect(help.stdout).toContain('dsh run "run the tests"') expect(help.stdout).toContain('dsh plugin --profile') expect(help.stdout).not.toMatch(/^\s+(?:tui|meta|upgrade)\b/mu) - for (const outsideGrammar of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['--profile', 'headless', 'task']]) { - const result = await runBuiltBin(outsideGrammar) + for (const removed of [['tui'], ['--config', 'x.yml'], ['-p', 'task'], ['run', 'task']]) { + const result = await runBuiltBin(removed) expect(result.code).toBe(1) } }, 30_000) - it('prints run help without initializing the selected profile', async () => { - const parent = mkdtempSync(join(tmpdir(), 'dsh-run-help-')) - const home = join(parent, 'not-created') + it('routes help and usage errors without activating startup-dependent rows', async () => { + const home = mkdtempSync(join(tmpdir(), 'dsh-app-help-')) try { - const result = await runBuiltBin(['run', '--help'], { DSH_HOME: home }) - expect(result.code).toBe(0) - expect(result.stderr).toBe('') - expect(result.stdout).toContain('Usage: dsh run [options] ') - expect(existsSync(home)).toBe(false) - } finally { - rmSync(parent, { recursive: true, force: true }) - } - }) + const web = await runBuiltBin(['--profile', 'web', '--help'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(web.code).toBe(0) + expect(web.stderr).toBe('') + expect(web.stdout).toContain('Usage: dsh --profile web') + expect(web.stdout).toContain('--port ') + expect(web.stdout).not.toContain('dsh web: http://') - it('runs the default headless profile through the published run command', async () => { - const apiKey = 'built-dsh-run-key' + const headlessHelp = await runBuiltBin(['--profile', 'headless', '--help'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(headlessHelp.code).toBe(0) + expect(headlessHelp.stderr).toBe('') + expect(headlessHelp.stdout).toContain('Usage: dsh --profile headless') + + const missingTask = await runBuiltBin(['--profile', 'headless'], { + DSH_HOME: home, + DSH_TELEMETRY_DISABLED: '1', + }) + expect(missingTask.code).toBe(1) + expect(missingTask.stderr).toContain('a task is required') + } finally { + rmSync(home, { recursive: true, force: true }) + } + }, 30_000) + + it('runs the headless profile through its app-owned task positional', async () => { + const apiKey = 'built-dsh-headless-key' const server = await startMockLlmServer({ sequence: ['success'], apiKey, - successText: 'published dsh run reached the mock', + successText: 'published headless profile reached the mock', }) - const home = mkdtempSync(join(tmpdir(), 'dsh-built-run-')) + const home = mkdtempSync(join(tmpdir(), 'dsh-built-headless-')) try { - const result = await runBuiltBin(['run', 'answer', 'from', 'the', 'published', 'entry'], { + const result = await runBuiltBin(['--profile', 'headless', 'answer', 'from', 'the', 'published', 'entry'], { DSH_HOME: home, DSH_TELEMETRY_DISABLED: '1', DEEPSEEK_API_KEY: apiKey, DEEPSEEK_BASE_URL: server.baseURL, }) expect(result.code, result.stderr).toBe(0) - expect(result.stdout).toBe('published dsh run reached the mock') + expect(result.stdout).toBe('published headless profile reached the mock') expect(result.stderr).toBe('') expect(server.requests.length).toBeGreaterThan(0) expect(server.requests.every(request => request.path === '/chat/completions')).toBe(true) @@ -258,7 +390,7 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', writeFileSync(join(project, '.env'), 'PATH=/project-only-path\n') try { const result = await runBuiltBin(['--version'], {}, project) - expect(result).toEqual({ code: 0, stdout: '0.0.1', stderr: '' }) + expect(result).toEqual({ code: 0, stdout: cliVersion, stderr: '' }) } finally { rmSync(project, { recursive: true, force: true }) } @@ -318,9 +450,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', }, 30_000) it('reports a patch-overlay boot failure without hanging', async () => { - // An HMR main-watcher initial scan that refreshes the include - // mid-initial-apply deadlocks the failing apply's rollback against the - // refresh drain: dsh exits 13 with no diagnostic instead of settling + // The HMR main watcher's initial scan once refreshed the include + // mid-initial-apply, deadlocking the failing apply's rollback against the + // refresh drain: dsh exited 13 with no diagnostic instead of settling // ([Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-03-hmr-initial-scan-boot-deadlock.md)). const home = mkdtempSync(join(tmpdir(), 'dsh-invalid-patch-')) try { @@ -337,9 +469,9 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) - it('applies a custom profile bundle and disposes it on a startup-time signal', async () => { + it('lets a profile without a parser ignore app arguments and dispose on a startup-time signal', async () => { const fixture = createProfileLifecycleFixture() - const child = startProfileLifecycle(fixture) + const child = startProfileLifecycle(fixture, ['--unclaimed']) try { await waitForFile(fixture.ready) requestProfileShutdown(child, fixture) @@ -405,6 +537,83 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', } }, 30_000) + it('hands the app arguments to the profile, which applies them before its rows start', async () => { + const fixture = createStartupFixture() + const child = startStartupProfile(fixture, ['--generation', 'flagged']) + try { + await waitForFile(fixture.ready) + // The consumer started once, already carrying the flag value: the + // launcher never saw --generation, and the app provider resolved it first. + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + requestProfileShutdown(child, fixture) + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it('starts a consumer on its composed value when the invocation carries no app arguments', async () => { + const fixture = createStartupFixture() + const child = startStartupProfile(fixture, []) + try { + await waitForFile(fixture.ready) + expect(readFileSync(fixture.echo, 'utf8')).toBe('bundle-default') + requestProfileShutdown(child, fixture) + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it('keeps the app arguments across a user patch reload', async () => { + // A live edit recomposes every row while the provider service remains + // active, so each config expression reads the same invocation value (a + // served port does not move back to its composed fallback). + const fixture = createStartupFixture() + const profilePatch = join(fixture.home, 'profiles', 'startup', 'cordis.patch.yml') + const child = startStartupProfile(fixture, ['--generation', 'flagged']) + try { + // Both rows: the waiting one carries the flag value, and the witness is + // what a reload will re-mount. They start independently, so neither + // marker implies the other. + await waitForFile(fixture.ready) + await waitForFile(fixture.witness) + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + // An edit to an unrelated row: the witness re-mounts, which is how this + // test knows the whole tree was recomposed. + rmSync(fixture.witness) + writeFileSync(profilePatch, [ + '- id: reload-witness', + ' config:', + ' generation: reloaded', + '', + ].join('\n')) + await waitForFile(fixture.witness) + expect(readFileSync(fixture.witness, 'utf8')).toBe('reloaded') + expect(readFileSync(fixture.echo, 'utf8')).toBe('flagged') + requestProfileShutdown(child, fixture) + expect((await child).exitCode).toBe(0) + } finally { + child.kill('SIGKILL') + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + + it("prints the app's own help, starts none of its rows, and exits", async () => { + const fixture = createStartupFixture() + try { + const result = await startStartupProfile(fixture, ['--help']) + expect(result.exitCode).toBe(0) + expect(result.stdout).toContain('Usage: fixture') + expect(result.stdout).toContain('--generation') + expect(existsSync(fixture.ready)).toBe(false) + } finally { + rmSync(fixture.home, { recursive: true, force: true }) + } + }, 30_000) + it('anchors a relative add spec to the invoking directory, not the profile', async () => { // `dsh plugin --profile x add .` from a plugin checkout must install THAT // checkout — pnpm's cwd is the profile directory, so an un-anchored `.` @@ -491,18 +700,17 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain("name: '@deepseek-ai/dsh-host-webserver'") }, 30_000) - it('prints a headless profile with no Host, HTTP, or browser rows', async () => { + it('prints the headless profile without Host or browser layers', async () => { const { stdout, code, stderr } = await runBuiltBin( ['--profile', 'headless', '--dump-default-config'], { DSH_HOME: home }, ) expect(code).toBe(0) expect(stderr).toBe('') - expect(stdout).toContain("name: '@deepseek-ai/dsh-agent-default-model'") expect(stdout).toContain("name: '@deepseek-ai/dsh-headless'") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-host-") + expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-host-/) expect(stdout).not.toContain("name: '@deepseek-ai/dsh-web-app'") - expect(stdout).not.toContain("name: '@deepseek-ai/dsh-client-") + expect(stdout).not.toMatch(/name: '@deepseek-ai\/dsh-client-/) }, 30_000) it('composes the profile user layer and a --patch overlay in order', async () => { @@ -543,16 +751,5 @@ describe.skipIf(!existsSync(dshBin))('dsh BUILT bin (node lib/bin.js, no tsx)', expect(stdout).toContain(`patched by ${profilePatch}, ${overlay}`) expect(stderr).toContain('patch: entry "absent-row" not found') }, 30_000) - - it('shows the RL Web patch disabling runtime surface context', async () => { - const { stdout, code, stderr } = await runBuiltBin( - ['web', '--patch', coreWebOverlay, '--dump-config'], - { DSH_HOME: home }, - ) - expect(code).toBe(0) - expect(stderr).toBe('') - expect(stdout).toContain("name: '@deepseek-ai/dsh-web-app'") - expect(stdout).toContain('surfaceContext: false') - }, 30_000) }) }) diff --git a/apps/cli/tests/dsh-badge.snapshot.ts b/apps/cli/tests/dsh-badge.snapshot.ts index 00d5278f82..28e418d6fc 100644 --- a/apps/cli/tests/dsh-badge.snapshot.ts +++ b/apps/cli/tests/dsh-badge.snapshot.ts @@ -85,14 +85,14 @@ describe('dsh badge assembled snapshot', () => { - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20 - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\` - - Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\` + - Project URL: \`https://github.com/deepseek-ai/deepseek-harness\` ## Markdown Use this linked badge in Markdown: \`\`\`markdown - [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) + [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) \`\`\` If attribution should not be linked, use: @@ -124,14 +124,14 @@ describe('dsh badge assembled snapshot', () => { - Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20 - Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\` - - Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\` + - Project URL: \`https://github.com/deepseek-ai/deepseek-harness\` ## Markdown Use this linked badge in Markdown: \`\`\`markdown - [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) + [![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) \`\`\` If attribution should not be linked, use: diff --git a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts index 8d6019ab53..98f3fd7578 100644 --- a/apps/cli/tests/fixtures/dsh-badge/snapshot.ts +++ b/apps/cli/tests/fixtures/dsh-badge/snapshot.ts @@ -1,5 +1,5 @@ import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json deleted file mode 100644 index 4851f9f1f8..0000000000 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/.mcp.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "mcpServers": { - "github_repository": { - "command": "node", - "args": [ - "lib/mcp-server.mjs" - ] - } - } -} diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json deleted file mode 100644 index 6871a4d053..0000000000 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/package.json +++ /dev/null @@ -1,30 +0,0 @@ -{ - "name": "dsh-github-repository-plugin-e2e-fixture", - "version": "0.0.0", - "private": true, - "type": "module", - "files": [ - "lib", - "dsh-plugin.mjs", - "dsh-plugin-assets" - ], - "scripts": { - "prepack": "tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare" - }, - "dsh": { - "skills": [ - "../skills" - ], - "mcpServers": "./.mcp.json", - "entry": "./lib/plugin.mjs" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0" - }, - "devDependencies": { - "@deepseek-ai/dsh-repository-plugin": "0.0.1", - "cordis": "4.0.0-rc.7", - "tsdown": "0.22.2", - "typescript": "6.0.3" - } -} diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts deleted file mode 100644 index 78a3e008f4..0000000000 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/mcp-server.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' -import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' - -// The repository root's linter cannot resolve this independently installed -// Git-package dependency; the package's prepack tsc validates the SDK types. -/* oxlint-disable typescript/no-unsafe-assignment, typescript/no-unsafe-call, typescript/no-unsafe-member-access */ -const server = new McpServer({ - name: 'github-repository-plugin-e2e', - version: '0.0.0', -}) - -server.registerTool('proof', { - description: 'Proves that an MCP server compiled from the exact GitHub repository package is active.', - inputSchema: {}, -}, async () => ({ - content: [{ type: 'text', text: 'MCP_FROM_GITHUB_REPOSITORY' }], -})) - -await server.connect(new StdioServerTransport()) diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts deleted file mode 100644 index 5106f71b2f..0000000000 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/src/plugin.ts +++ /dev/null @@ -1,59 +0,0 @@ -import type { Context } from 'cordis' - -const PROOF_TOOL_NAME = 'mcp__github_repository__proof' - -interface TextBlock { - readonly type: 'text' - readonly text: string -} - -interface ToolExecution { - readonly name: string -} - -interface ToolResult { - readonly isError: boolean - readonly content: readonly TextBlock[] -} - -type PostDecision = - | { readonly kind: 'accept'; readonly content?: readonly TextBlock[]; readonly value?: unknown; readonly additionalContexts?: readonly unknown[] } - | { readonly kind: 'block'; readonly feedback: readonly TextBlock[] } - -type PostListener = ( - execution: ToolExecution, - result: ToolResult, - next: () => Promise, -) => Promise - -type DshContext = Context & { - on(event: 'tools/post-execute', listener: PostListener): () => void -} - -/** Cordis plugin name used by the repository acceptance fixture. */ -export const name = 'github-repository-typescript-proof' - -/** DSH tool registry required by the post-execute contribution. */ -export const inject = ['tools'] - -/** - * Append a marker after the repository MCP proof tool succeeds. - * @param ctx - trusted DSH Cordis context supplied to the repository package. - */ -export function apply(ctx: Context): void { - const dsh = ctx as DshContext - dsh.on('tools/post-execute', async (execution, result, next): Promise => { - const decision = await next() - if (execution.name !== PROOF_TOOL_NAME || result.isError || decision.kind !== 'accept' || Object.hasOwn(decision, 'value')) { - return decision - } - return { - kind: 'accept', - content: [ - ...(decision.content ?? result.content), - { type: 'text', text: 'TS_PLUGIN_FROM_GITHUB_REPOSITORY' }, - ], - ...decision.additionalContexts === undefined ? {} : { additionalContexts: decision.additionalContexts }, - } - }) -} diff --git a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json b/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json deleted file mode 100644 index 22d9301cfb..0000000000 --- a/apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin/tsconfig.json +++ /dev/null @@ -1,13 +0,0 @@ -{ - "compilerOptions": { - "target": "ES2024", - "module": "ESNext", - "moduleResolution": "Bundler", - "strict": true, - "skipLibCheck": true, - "noEmit": true - }, - "include": [ - "src/**/*.ts" - ] -} diff --git a/apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md b/apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md deleted file mode 100644 index eae668a4f9..0000000000 --- a/apps/cli/tests/fixtures/github-repository-plugin/skills/github-source-proof/SKILL.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -name: github-source-proof -description: Proves that dsh installed a private repository Plugin from an exact GitHub source. ---- - -This skill exists only in the GitHub repository source fixture. diff --git a/apps/cli/tests/fixtures/never-dispose.mjs b/apps/cli/tests/fixtures/never-dispose.mjs index 5dc8510550..9e81b32738 100644 --- a/apps/cli/tests/fixtures/never-dispose.mjs +++ b/apps/cli/tests/fixtures/never-dispose.mjs @@ -4,7 +4,7 @@ import { existsSync } from 'node:fs' /** * Register a disposer that keeps process shutdown pending until it is forced. - * @param {import('cordis').Context} ctx - loader-mounted test plugin context. + * @param {import('@deepseek-ai/cordis').Context} ctx - loader-mounted test plugin context. */ export function apply(ctx) { const keepAlive = setInterval(() => {}, 60_000) diff --git a/apps/cli/tests/github-repository-plugin.built.e2e.ts b/apps/cli/tests/github-repository-plugin.built.e2e.ts deleted file mode 100644 index 7bb4706dd7..0000000000 --- a/apps/cli/tests/github-repository-plugin.built.e2e.ts +++ /dev/null @@ -1,256 +0,0 @@ -import { createHash } from 'node:crypto' -import { cpSync, existsSync, globSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmSync, writeFileSync } from 'node:fs' -import { createServer } from 'node:http' -import { createRequire } from 'node:module' -import { tmpdir } from 'node:os' -import { delimiter, join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { startMockLlmServer } from '@deepseek-ai/dsh-llm-mock-server' -import { execa } from 'execa' -import { describe, expect, it } from 'vitest' - -const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) -const dshBin = join(repoRoot, 'apps/cli/lib/bin.js') -const repositoryPluginPackage = join(repoRoot, 'packages/self-modification/repository-plugin') -const releasePackageNames = new Set(globSync([ - 'vendor/*/package.json', - 'packages/*/*/package.json', - 'apps/*/package.json', -], { cwd: repoRoot }).map((filename) => { - const manifest = JSON.parse(readFileSync(join(repoRoot, filename), 'utf8')) as Record - if (typeof manifest.name !== 'string') throw new Error(`workspace package name is missing: ${filename}`) - return manifest.name -})) -const source = process.env.DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE -const required = process.env.DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E === '1' -const enabled = required || source !== undefined - -interface PublishedPackageRegistry { - url: string - requests: string[] - close(): Promise -} - -function publishedManifest(): Record { - const manifest = JSON.parse(readFileSync(join(repositoryPluginPackage, 'package.json'), 'utf8')) as Record - const version = manifest.version - if (typeof version !== 'string') throw new Error('repository Plugin package version is missing') - Reflect.deleteProperty(manifest, 'private') - for (const field of ['dependencies', 'devDependencies', 'optionalDependencies', 'peerDependencies']) { - const dependencies = manifest[field] - if (typeof dependencies !== 'object' || dependencies === null || Array.isArray(dependencies)) continue - const entries = dependencies as Record - for (const name of Object.keys(entries)) { - if (releasePackageNames.has(name)) { - entries[name] = version - } - } - } - return manifest -} - -async function startPublishedPackageRegistry(root: string): Promise { - const staging = join(root, 'published-repository-plugin') - const artifacts = join(root, 'npm-registry-artifacts') - mkdirSync(staging) - mkdirSync(artifacts) - cpSync(join(repositoryPluginPackage, 'lib'), join(staging, 'lib'), { recursive: true }) - for (const filename of ['README.md', 'README.zh.md', 'README.i18n.yaml']) { - cpSync(join(repositoryPluginPackage, filename), join(staging, filename)) - } - cpSync(join(repoRoot, 'LICENSE'), join(staging, 'LICENSE')) - const manifest = publishedManifest() - writeFileSync(join(staging, 'package.json'), `${JSON.stringify(manifest, undefined, 2)}\n`) - const packed = await execa('pnpm', ['pack', '--pack-destination', artifacts], { - cwd: staging, - reject: false, - }) - if (packed.exitCode !== 0) { - throw new Error(`failed to pack the simulated published prepare package:\n${packed.stderr}\n${packed.stdout}`) - } - const tarballs = readdirSync(artifacts).filter(filename => filename.endsWith('.tgz')) - if (tarballs.length !== 1) throw new Error(`expected one simulated published tarball, found ${tarballs.length}`) - const tarball = readFileSync(join(artifacts, tarballs[0]!)) - const name = manifest.name as string - const version = manifest.version as string - const requests: string[] = [] - let registryUrl = '' - const server = createServer((request, response) => { - const path = decodeURIComponent(new URL(request.url ?? '/', registryUrl).pathname) - requests.push(`${request.method ?? 'GET'} ${path}`) - if (path === `/${name}`) { - const metadata = { - name, - 'dist-tags': { latest: version }, - versions: { - [version]: { - ...manifest, - dist: { - tarball: `${registryUrl}${name}/-/${name.split('/').at(-1)}-${version}.tgz`, - shasum: createHash('sha1').update(tarball).digest('hex'), - integrity: `sha512-${createHash('sha512').update(tarball).digest('base64')}`, - }, - }, - }, - } - response.writeHead(200, { 'content-type': 'application/json' }) - response.end(JSON.stringify(metadata)) - return - } - if (path === `/${name}/-/${name.split('/').at(-1)}-${version}.tgz`) { - response.writeHead(200, { - 'content-type': 'application/octet-stream', - 'content-length': String(tarball.length), - }) - response.end(tarball) - return - } - response.writeHead(404, { 'content-type': 'application/json' }) - response.end(JSON.stringify({ error: 'not found' })) - }) - 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('simulated npm registry did not expose a TCP address') - registryUrl = `http://127.0.0.1:${address.port}/` - return { - url: registryUrl, - requests, - close: () => new Promise((resolve, reject) => { - server.close((error) => { if (error === undefined) resolve(); else reject(error) }) - }), - } -} - -describe.skipIf(!enabled)('dsh run GitHub repository Plugin installation', () => { - it('installs the published prepare dependency, then builds and runs skill, MCP, and TypeScript Plugin contributions from a private exact GitHub source', async () => { - expect(existsSync(dshBin), 'the repository Plugin acceptance must run the built dsh entry').toBe(true) - expect(source, 'DSH_GITHUB_REPOSITORY_PLUGIN_SOURCE is required by this CI lane').toMatch( - /^github:[^/\s#&]+\/[^/\s#&]+#[0-9a-f]{40}&path:\/.*\/\.dsh-plugin$/u, - ) - - const apiKey = 'github-repository-plugin-e2e-key' - const server = await startMockLlmServer({ - sequence: ['tool_call_success', 'success'], - apiKey, - toolName: 'mcp__github_repository__proof', - toolArguments: '{}', - successText: 'trusted GitHub repository package reached dsh run', - }) - const home = mkdtempSync(join(tmpdir(), 'dsh-github-repository-plugin-')) - const registry = await startPublishedPackageRegistry(home) - const npmrc = join(home, 'npmrc') - writeFileSync(npmrc, `@deepseek-ai:registry=${registry.url}\n`) - const hostBin = join(home, 'host-bin') - mkdirSync(hostBin) - writeFileSync(join(hostBin, 'dsh-plugin-prepare'), [ - '#!/bin/sh', - 'echo "host PATH supplied dsh-plugin-prepare instead of the declared npm dependency" >&2', - 'exit 91', - '', - ].join('\n'), { mode: 0o700 }) - const patch = join(home, 'github-repository-plugin.cordis.patch.yml') - writeFileSync(patch, [ - '- id: repository-plugins', - ' config:', - ' repositories:', - ` - ${JSON.stringify(source)}`, - '- id: session-title-llm', - ' disabled: true', - '', - ].join('\n')) - - try { - const result = await execa(process.execPath, [ - dshBin, - 'run', - '--patch', - patch, - 'prove the private GitHub repository Plugin is active', - ], { - cwd: repoRoot, - input: '', - timeout: 180_000, - killSignal: 'SIGKILL', - reject: false, - env: { - ...process.env, - DSH_HOME: home, - DSH_TELEMETRY_DISABLED: '1', - DEEPSEEK_API_KEY: apiKey, - DEEPSEEK_BASE_URL: server.baseURL, - NPM_CONFIG_USERCONFIG: npmrc, - // A warm runner cache could satisfy the exact tarball without - // contacting this test's registry, which would stop proving the - // unpublished package was installed through the simulated release. - PNPM_CONFIG_CACHE_DIR: join(home, 'pnpm-cache'), - PNPM_CONFIG_STORE_DIR: join(home, 'pnpm-store'), - PATH: process.env.PATH === undefined ? hostBin : `${hostBin}${delimiter}${process.env.PATH}`, - }, - }) - if (result.timedOut) { - throw new Error(`dsh GitHub repository Plugin run did not exit within 180s. stdout:\n${result.stdout}\nstderr:\n${result.stderr}`) - } - expect(result.exitCode, `${result.stderr}\nstdout:\n${result.stdout}`).toBe(0) - expect(result.stdout).toBe('trusted GitHub repository package reached dsh run') - expect(server.requests).toHaveLength(2) - const runtimeDiagnostic = `${result.stderr}\nstdout:\n${result.stdout}` - expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin') - expect(registry.requests, runtimeDiagnostic).toContain('GET /@deepseek-ai/dsh-repository-plugin/-/dsh-repository-plugin-0.0.1.tgz') - const firstRequest = JSON.stringify(server.requests[0]!.body) - const secondRequest = JSON.stringify(server.requests[1]!.body) - expect(firstRequest, runtimeDiagnostic).toContain( - 'Proves that dsh installed a private repository Plugin from an exact GitHub source.', - ) - expect(firstRequest, runtimeDiagnostic).toContain('mcp__github_repository__proof') - expect(firstRequest, runtimeDiagnostic).toContain('Proves that an MCP server compiled from the exact GitHub repository package is active.') - expect(secondRequest, runtimeDiagnostic).toContain('MCP_FROM_GITHUB_REPOSITORY') - expect(secondRequest, runtimeDiagnostic).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY') - - const cacheRoot = join(home, 'cache', 'repository-plugins') - const generations = readdirSync(cacheRoot, { withFileTypes: true }).filter(entry => entry.isDirectory()) - expect(generations).toHaveLength(1) - const installed = join(cacheRoot, generations[0]!.name, 'node_modules', 'repository') - const manifest = JSON.parse(readFileSync(join(installed, 'package.json'), 'utf8')) as Record - expect(manifest).toMatchObject({ - name: 'dsh-github-repository-plugin-e2e-fixture', - private: true, - scripts: { - prepack: 'tsc --noEmit && tsdown src/plugin.ts src/mcp-server.ts --no-config --tsconfig tsconfig.json --out-dir lib --platform node --target es2024 --clean && dsh-plugin-prepare', - }, - dsh: { - skills: ['../skills'], - mcpServers: './.mcp.json', - entry: './lib/plugin.mjs', - }, - dependencies: { - '@modelcontextprotocol/sdk': '1.29.0', - }, - devDependencies: { - '@deepseek-ai/dsh-repository-plugin': '0.0.1', - cordis: '4.0.0-rc.7', - tsdown: '0.22.2', - typescript: '6.0.3', - }, - }) - expect(readFileSync(join(installed, 'dsh-plugin-assets/skills/0/github-source-proof/SKILL.md'), 'utf8')) - .toContain('This skill exists only in the GitHub repository source fixture.') - expect(readFileSync(join(installed, 'dsh-plugin-assets/.mcp.json'), 'utf8')).toContain('lib/mcp-server.mjs') - expect(readFileSync(join(installed, 'lib/plugin.mjs'), 'utf8')).toContain('TS_PLUGIN_FROM_GITHUB_REPOSITORY') - expect(readFileSync(join(installed, 'lib/mcp-server.mjs'), 'utf8')).toContain('MCP_FROM_GITHUB_REPOSITORY') - expect(existsSync(join(installed, 'src'))).toBe(false) - const installedRequire = createRequire(join(installed, 'lib/mcp-server.mjs')) - expect(existsSync(installedRequire.resolve('@modelcontextprotocol/sdk/server/mcp.js'))).toBe(true) - const wrapper = readFileSync(join(installed, 'dsh-plugin.mjs'), 'utf8') - expect(wrapper).toContain('dsh-repository-plugin') - expect(wrapper).toContain('await import(manifest.entry)') - expect(wrapper).toContain('"entry":"./lib/plugin.mjs"') - } finally { - await server.close() - await registry.close() - rmSync(home, { recursive: true, force: true }) - } - }, 190_000) -}) diff --git a/apps/cli/tests/headless-shutdown.e2e.ts b/apps/cli/tests/headless-shutdown.e2e.ts index b0bcbb7cae..42f2aad709 100644 --- a/apps/cli/tests/headless-shutdown.e2e.ts +++ b/apps/cli/tests/headless-shutdown.e2e.ts @@ -83,7 +83,7 @@ async function runHeadlessPtySmoke(): Promise { ].join('\n')) const launch = resolveExampleLaunch({ srcBin: dshBinScript, - configArgs: ['run', 'never complete'], + configArgs: ['--profile', 'headless', 'never complete'], tsconfigPath, env: { DSH_HOME: home, diff --git a/apps/cli/tests/install-script.spec.ts b/apps/cli/tests/install-script.spec.ts deleted file mode 100644 index 871f72d056..0000000000 --- a/apps/cli/tests/install-script.spec.ts +++ /dev/null @@ -1,140 +0,0 @@ -import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' -import { mkdtemp, rm } from 'node:fs/promises' -import { tmpdir } from 'node:os' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { execa } from 'execa' -import { afterEach, describe, expect, it } from 'vitest' - -const installer = fileURLToPath(new URL('../../../scripts/install.sh', import.meta.url)) -const fixtures: string[] = [] - -const PTY_DRIVER = String.raw` -import errno, json, os, pty, select, signal, sys, time -script, cwd, env_json, actions_json = sys.argv[1:] -env = os.environ.copy() -env.update(json.loads(env_json)) -actions = json.loads(actions_json) -pid, fd = pty.fork() -if pid == 0: - os.chdir(cwd) - os.execvpe("sh", ["sh", script], env) - -output = bytearray() -action_index = 0 -deadline = time.monotonic() + 15 -status = None -while time.monotonic() < deadline: - ready, _, _ = select.select([fd], [], [], 0.05) - if ready: - try: - chunk = os.read(fd, 65536) - except OSError as error: - if error.errno != errno.EIO: - raise - chunk = b"" - output.extend(chunk) - while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output: - os.write(fd, actions[action_index]["send"].encode()) - action_index += 1 - waited, candidate = os.waitpid(pid, os.WNOHANG) - if waited == pid: - status = candidate - break - -if status is None: - os.kill(pid, signal.SIGKILL) - _, status = os.waitpid(pid, 0) -sys.stdout.buffer.write(output) -if action_index != len(actions): - sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions\n") - sys.exit(124) -sys.exit(os.waitstatus_to_exitcode(status)) -` - -interface Action { - readonly waitFor: string - readonly send: string -} - -interface Fixture { - readonly binDirectory: string - readonly launchLog: string - readonly pnpmLog: string - readonly root: string - readonly script: string -} - -afterEach(async () => { - await Promise.all(fixtures.splice(0).map(async (fixture) => { await rm(fixture, { force: true, recursive: true }) })) -}) - -function executable(path: string, content: string): void { - writeFileSync(path, content) - chmodSync(path, 0o755) -} - -async function createFixture(): Promise { - const root = await mkdtemp(join(tmpdir(), 'dsh-install-')) - fixtures.push(root) - const checkoutDirectory = join(root, 'checkout') - const scriptsDirectory = join(checkoutDirectory, 'scripts') - const sourceBinDirectory = join(checkoutDirectory, 'bin') - const fakeBinDirectory = join(root, 'fake-bin') - const binDirectory = join(root, 'path-bin') - for (const directory of [scriptsDirectory, sourceBinDirectory, fakeBinDirectory, binDirectory, join(root, 'home/.dsh')]) { - mkdirSync(directory, { recursive: true }) - } - const script = join(scriptsDirectory, 'install.sh') - copyFileSync(installer, script) - const launchLog = join(root, 'launch.log') - const pnpmLog = join(root, 'pnpm.log') - executable(join(sourceBinDirectory, 'dsh'), '#!/bin/sh\nprintf \'%s\\n\' "$*" >"$DSH_TEST_LAUNCH_LOG"\n') - executable(join(fakeBinDirectory, 'pnpm'), `#!/bin/sh -if [ "\${1:-}" = --version ]; then printf '11.7.0\\n'; exit 0; fi -printf '%s\\n' "$*" >>"$DSH_TEST_PNPM_LOG" -`) - await execa('git', ['init', '-q'], { cwd: checkoutDirectory }) - await execa('git', ['add', 'bin/dsh', 'scripts/install.sh'], { cwd: checkoutDirectory }) - await execa('git', [ - '-c', 'user.name=dsh-test', - '-c', 'user.email=dsh-test@example.invalid', - 'commit', '-qm', 'fixture', - ], { cwd: checkoutDirectory }) - writeFileSync(join(root, 'home/.dsh/.env'), 'DEEPSEEK_API_KEY=test\n') - return { binDirectory, launchLog, pnpmLog, root, script } -} - -async function runInstaller(fixture: Fixture, actions: readonly Action[]): Promise { - const result = await execa('python3', [ - '-c', - PTY_DRIVER, - fixture.script, - fixture.root, - JSON.stringify({ - DSH_BIN_DIR: fixture.binDirectory, - DSH_HOME: join(fixture.root, 'home/.dsh'), - DSH_TEST_LAUNCH_LOG: fixture.launchLog, - DSH_TEST_PNPM_LOG: fixture.pnpmLog, - HOME: join(fixture.root, 'home'), - PATH: `${join(fixture.root, 'fake-bin')}:${fixture.binDirectory}:${process.env.PATH ?? ''}`, - }), - JSON.stringify(actions), - ], { reject: false, stripFinalNewline: false, timeout: 20_000 }) - expect(result.exitCode, result.stderr).toBe(0) - return result.stdout -} - -describe.runIf(process.platform !== 'win32')('one-line installer launch', { timeout: 25_000 }, () => { - it('builds and launches the Web UI', async () => { - const fixture = await createFixture() - - const output = await runInstaller(fixture, [ - { waitFor: 'Replace it?', send: '\n' }, - ]) - - expect(output).toContain('launching Web UI') - expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\nrun build\n') - expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n') - }) -}) diff --git a/apps/cli/tests/memory-mcp-configs.spec.ts b/apps/cli/tests/memory-mcp-configs.spec.ts index 818b8f4561..ee8905c3b6 100644 --- a/apps/cli/tests/memory-mcp-configs.spec.ts +++ b/apps/cli/tests/memory-mcp-configs.spec.ts @@ -8,8 +8,8 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' -import type { PatchOptions } from '@cordisjs/plugin-include' +import type { Context } from '@deepseek-ai/cordis' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { boot, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' diff --git a/apps/cli/tests/source-launch.compat.spec.ts b/apps/cli/tests/source-launch.compat.spec.ts index bd2268231b..597c3cff26 100644 --- a/apps/cli/tests/source-launch.compat.spec.ts +++ b/apps/cli/tests/source-launch.compat.spec.ts @@ -1,11 +1,12 @@ +import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' import { execa } from 'execa' import { describe, expect, it } from 'vitest' /** - * Keyless smoke for the SOURCE `dsh` launcher: run `apps/cli/src/bin.ts` - * with the exact production launch vector (`node --import tsx/esm`, the same - * executable and arguments as `bin/dsh` and the root `dsh`/`demo:web` scripts) and assert the + * Keyless smoke for SOURCE `dsh` execution: run `apps/cli/src/bin.ts` + * with the exact production runtime vector (`node --import tsx/esm`, the + * vector the root `dsh` script invokes after building) and assert the * required-config diagnostic. The Node compatibility matrix runs this * WHOLE file, so a Node release changing module hooks or TypeScript handling * breaks this gate instead of every developer's `pnpm dsh`; the built-bin @@ -16,6 +17,13 @@ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url)) const dshSourceBin = 'apps/cli/src/bin.ts' describe('dsh SOURCE launcher (node --import tsx/esm)', () => { + it('builds before launching the source CLI', async () => { + const rootPackage = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as { + readonly scripts?: Record + } + expect(rootPackage.scripts?.dsh).toBe('pnpm run build && node --import tsx/esm apps/cli/src/bin.ts') + }) + it('boots the source entry and requires a profile', async () => { const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], { cwd: repoRoot, diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 004de203c8..7947fb0807 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -3,18 +3,23 @@ import { mkdir, mkdtemp, readFile, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { fileURLToPath } from 'node:url' import { dirname, join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { boot, healProfilesModuleFallback, loadOverlayPatches } from '@deepseek-ai/dsh-app-boot' +import { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { PatchOptions } from '@cordisjs/plugin-include' -import { beforeAll, describe, expect, it } from 'vitest' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { resolveSessionPreset, SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-presets' import { applyChildComposition, childSessionMeta } from '@deepseek-ai/dsh-subagent' import { CallId } from '@deepseek-ai/dsh-llm' +import type { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import type {} from '@deepseek-ai/dsh-skill' import type {} from '@deepseek-ai/dsh-tools' +// Type-only: resolves `ctx.get('sessionProjections')` and `ctx.get('tokenMeter')`. +import type {} from '@deepseek-ai/dsh-session-projection' +import type {} from '@deepseek-ai/dsh-token-meter' const CONFIG_DIR = fileURLToPath(new URL('../config/', import.meta.url)) const REPO_ROOT = fileURLToPath(new URL('../../..', import.meta.url)) @@ -23,6 +28,15 @@ const BASE_PATCH = join(REPO_ROOT, 'packages/bundle/base/cordis.patch.yml') const WEB_PATCH = join(REPO_ROOT, 'packages/bundle/web-app/cordis.patch.yml') /** The installation anchor whose dependency surface the preset module fallback mirrors. */ const INSTALL_ANCHOR = join(REPO_ROOT, 'apps/cli/package.json') +const MINIMAL_PROMPT = 'You are a helpful software engineer assistant.' +const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell +* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. +* You don't have access to the internet via this tool. +* You do have access to a mirror of common linux and python packages via apt and pip. +* State is persistent across command calls and discussions with the user. +* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. +* Please avoid commands that may produce a very large amount of output. +* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.` /** * Boot the shipped Web composition, minus the rows that would bind a port, @@ -90,12 +104,26 @@ async function bootWeb(settingsFile: string, extra: PatchOptions[] = []): Promis await mkdir(profileDir, { recursive: true }) const rootConfig = join(profileDir, 'cordis.yml') await writeFile(rootConfig, '[]\n') - return await boot('dsh-test', rootConfig, patches) + return await boot('dsh-test', rootConfig, patches, (bootCtx) => { + provideCmdline(bootCtx, { args: [], exit: () => {} }) + }) } const toolNames = (ctx: Context, agent?: Agent): string[] => ctx.tools.schemas(agent).map(schema => schema.name).sort() +function enablePresetTool(composition: string, id: string): string { + const row = ` - id: ${id}\n` + const start = composition.indexOf(row) + if (start < 0) throw new Error(`missing preset row ${id}`) + const end = composition.indexOf('\n - id:', start + row.length) + const disabled = composition.indexOf(' disabled: true\n', start) + if (disabled < 0 || (end >= 0 && disabled > end)) { + throw new Error(`preset row ${id} is not disabled`) + } + return composition.slice(0, disabled) + composition.slice(disabled + ' disabled: true\n'.length) +} + let ctx: Context beforeAll(async () => { const settingsFile = join(await mkdtemp(join(tmpdir(), 'dsh-web-presets-')), 'settings.yaml') @@ -113,6 +141,33 @@ describe('the shipped Web composition', () => { expect(toolNames(ctx)).toEqual([]) }) + it('keeps the token meter and its context-meter projections on the host plane', async () => { + // Read before any preset in this file mounts, which is what makes this an + // ownership assertion rather than a mount-order coincidence: a preset-side + // meter sits behind an `isolate` realm and is invisible to `ctx.get`. + // + // The projection registry is process-wide rather than scope-layered, so a + // preset-side meter would also make the browser's context meter appear for + // a `minimal` session the moment some OTHER session mounted a preset that + // carries one, and vanish entirely in a process that only ever ran + // `minimal`. Host ownership is what makes the meter a per-session fact. + expect(ctx.get('tokenMeter')).toBeDefined() + const projections = ctx.get('sessionProjections') + if (projections === undefined) throw new Error('the Web composition must compose a projection registry') + const handle = await ctx.agents.create({ + sessionId: SessionId('preset-minimal-meter'), + setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + try { + // A subset assertion: `tasks`, `goal`, and the rest register into the + // same process-wide table, and this is about the meter's three units. + expect(Object.keys(projections.snapshot(handle.agent.session).values)) + .toEqual(expect.arrayContaining(['contextBreakdown', 'contextPressure', 'tokenUsage'])) + } finally { + await handle.dispose() + } + }) + it('supplies both shipped presets, and only those, from the system root', async () => { const listed = await ctx.agentPresets.list() @@ -134,8 +189,8 @@ describe('the shipped Web composition', () => { // depend on ripgrep being present on the machine. expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([ 'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode', - 'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill', - 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill', + 'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill', + 'subagent', 'subagent_fork', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search', 'workflow', 'write', ]) @@ -144,14 +199,30 @@ describe('the shipped Web composition', () => { } }) - it('composes exactly two tools from `minimal`', async () => { + it('composes the exact RL prompt and two tools from `minimal`', async () => { const handle = await ctx.agents.create({ sessionId: SessionId('preset-minimal'), setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), }) try { - // Exactly what the preset lists — nothing arrives from the host. - expect(toolNames(ctx, handle.agent)).toEqual(['bash', 'str_replace_editor']) + const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) + expect(assembly.sections).toEqual([ + { name: 'deployment:persona', text: MINIMAL_PROMPT }, + ]) + expect(assembly.tools.map(tool => tool.name)).toEqual(['bash', 'str_replace_editor']) + expect(assembly.tools.find(tool => tool.name === 'bash')?.description).toBe(MINIMAL_BASH_DESCRIPTION) + expect(JSON.stringify(assembly.tools.find(tool => tool.name === 'str_replace_editor')?.parameters)) + .toContain('Absolute path') + const compact = ctx.agentPresets.serviceFor(handle.agent, 'compact') + expect(compact).toBeDefined() + expect((compact as BasicCompactService).config).toMatchObject({ + thresholdRatio: 0.8, + retainTokens: 20480, + summarizationProvider: '', + summarizationModel: '', + maxTokens: 8192, + compactionRetries: 1, + }) } finally { await handle.dispose() } @@ -191,6 +262,7 @@ describe('the shipped Web composition', () => { expect(tools).toEqual(expect.arrayContaining(['cordis_inspect', 'cordis_mount', 'cordis_unmount'])) // And it keeps the standard agent's own tools rather than replacing them. expect(tools).toEqual(expect.arrayContaining(['bash', 'read', 'edit', 'skill'])) + expect(tools).not.toContain('str_replace_editor') // The preset's own authoring skill registers into ITS layer of the host // registry: the cordis agent's view carries it, the global view does not. @@ -217,9 +289,9 @@ describe('the shipped Web composition', () => { // the capabilities — so the assembly is what carries the claim. const assembly = await ctx.systemPrompt.assemble({ scope: coded.agent }) expect(assembly.tools.map(tool => tool.name)).toEqual(['run_code']) - expect(toolNames(ctx, coded.agent)).toContain('str_replace_editor') + expect(toolNames(ctx, coded.agent)).not.toContain('str_replace_editor') const sdk = assembly.sections.find(section => section.name === 'tools:sdk')?.text ?? '' - expect(sdk).toContain('str_replace_editor') + expect(sdk).not.toContain('str_replace_editor') expect(sdk).toContain('web_search') // The presentation is this agent's alone: the deployment default is @@ -340,18 +412,96 @@ describe('the shipped Web composition', () => { expect(await readFile(path, 'utf8')).toBe(before) }) +}) - it('gives each session its own persona', async () => { - const handle = await ctx.agents.create({ - sessionId: SessionId('preset-persona'), - setup: agentCtx => ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), +describe('product subagent rows in user presets', () => { + let productCtx: Context + const ids = ['products-none', 'products-codex', 'products-claude', 'products-both'] as const + + beforeAll(async () => { + const root = await mkdtemp(join(tmpdir(), 'dsh-product-presets-')) + const userRoot = join(root, 'presets') + const settingsFile = join(root, 'settings.yaml') + const standard = await readFile(join(CONFIG_DIR, 'agent-presets', 'standard', 'agent.cordis.yml'), 'utf8') + await writeFile(settingsFile, '{}\n') + for (const id of ids) { + let composition = standard + if (id === 'products-codex' || id === 'products-both') { + composition = enablePresetTool(composition, 'tool-subagent-codex') + } + if (id === 'products-claude' || id === 'products-both') { + composition = enablePresetTool(composition, 'tool-subagent-claude-code') + } + const directory = join(userRoot, id) + await mkdir(directory, { recursive: true }) + await writeFile(join(directory, 'agent.cordis.yml'), composition) + } + productCtx = await bootWeb(settingsFile, [{ + id: 'agent-presets', + config: { + default: 'standard', + roots: [ + { path: join(CONFIG_DIR, 'agent-presets'), trust: 'system' }, + { path: userRoot, trust: 'user' }, + ], + }, + }]) + }, 120_000) + + afterAll(async () => { + await productCtx.fiber.dispose() + }) + + it('composes none, either product, or both without changing the shared host registry', async () => { + const expected = new Map([ + ['products-none', []], + ['products-codex', ['subagent_codex']], + ['products-claude', ['subagent_claude_code']], + ['products-both', ['subagent_claude_code', 'subagent_codex']], + ]) + expect(productCtx.subagents.list()).toEqual(expect.arrayContaining([ + 'spawn', 'fork', 'codex', 'claude-code', + ])) + + for (const [id, productTools] of expected) { + const handle = await productCtx.agents.create({ + sessionId: SessionId(`preset-${id}`), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, id).then(() => undefined), + }) + try { + const tools = toolNames(productCtx, handle.agent) + expect(tools.filter(name => name === 'subagent_codex' || name === 'subagent_claude_code')) + .toEqual(productTools) + } finally { + await handle.dispose() + } + } + }) + + it('applies a product-row edit only to later sessions on the preset', async () => { + const preset = await productCtx.agentPresets.resolve('products-none') + const original = await readFile(preset.path, 'utf8') + const existing = await productCtx.agents.create({ + sessionId: SessionId('preset-product-generation-existing'), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), }) try { - const assembly = await ctx.systemPrompt.assemble({ scope: handle.agent }) - expect(assembly.sections.find(section => section.name === 'deployment:persona')?.text) - .toContain('You are a coding agent powered by') + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') + await writeFile(preset.path, enablePresetTool(original, 'tool-subagent-codex')) + + const later = await productCtx.agents.create({ + sessionId: SessionId('preset-product-generation-later'), + setup: agentCtx => productCtx.agentPresets.mount(agentCtx, 'products-none').then(() => undefined), + }) + try { + expect(toolNames(productCtx, existing.agent)).not.toContain('subagent_codex') + expect(toolNames(productCtx, later.agent)).toContain('subagent_codex') + } finally { + await later.dispose() + } } finally { - await handle.dispose() + await existing.dispose() + await writeFile(preset.path, original) } }) }) diff --git a/apps/cli/tsconfig.json b/apps/cli/tsconfig.json index d830e8fba6..cecf7a4bb9 100644 --- a/apps/cli/tsconfig.json +++ b/apps/cli/tsconfig.json @@ -20,6 +20,9 @@ { "path": "../../packages/boot/app-boot" }, + { + "path": "../../packages/boot/cmdline" + }, { "path": "../../packages/bundle/base" }, @@ -47,6 +50,9 @@ { "path": "../../packages/core/tools" }, + { + "path": "../../packages/util/environment" + }, { "path": "../../packages/util/paths" }, diff --git a/apps/web/package.json b/apps/web/package.json index c58e5b9682..aed4488355 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-frontend", "description": "Web application entry: vite build over the @deepseek-ai/dsh-client-web shell library; dist/ served by apps/cli's dsh web", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "apps/web" + }, "type": "module", "exports": { "./dist/*": "./dist/*", @@ -23,11 +30,12 @@ "react-dom": "^18.2.0" }, "devDependencies": { - "@cordisjs/plugin-group": "workspace:^", + "@deepseek-ai/cordis-plugin-group": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@types/node": "^22.0.0", "@types/react": "~18.3.1", @@ -36,6 +44,7 @@ "playwright": "^1.49.0", "typescript": "^6.0.3", "vite": "^6.0.0", - "vitest": "^4.1.8" + "vitest": "^4.1.8", + "fflate": "^0.8.2" } } diff --git a/apps/web/public/favicon.svg b/apps/web/public/favicon.svg index 8a8fc56752..c92f15d43b 100644 --- a/apps/web/public/favicon.svg +++ b/apps/web/public/favicon.svg @@ -1,3 +1,8 @@ + - \ No newline at end of file + diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index e8ff5aa538..1a27f96c6e 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -161,7 +161,7 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { expect(composition).toBe(await readFile(join(SHIPPED_PRESETS, 'minimal', 'agent.cordis.yml'), 'utf8')) const metadata = await readFile(join(userRoot, 'my-agent', 'preset.yml'), 'utf8') expect(metadata).toContain('name: 我的模式') - expect(metadata).toContain('description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。') + expect(metadata).toContain('description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。') expect(metadata).not.toContain('order:') }, 60_000) @@ -176,8 +176,10 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0) expect(existsSync(join(userRoot, 'my-agent'))).toBe(false) - // Custom group gone with its only member; the shipped set stands. - expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0) + // The custom group outlives its only member: the heading stays with the + // creator entry so the place to author a preset never disappears. + expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1) + expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1) expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0) }, 60_000) @@ -194,18 +196,18 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => { const dialog = settingsDialog() await dialog.getByRole('button', { name: '通用设置' }).click() await dialog.getByRole('button', { name: 'Agent 预设' }).click() - await dialog.getByText('已损坏').first().waitFor({ timeout: 10_000 }) + await dialog.getByText('加载失败').first().waitFor({ timeout: 10_000 }) const snapshot = withPresetRoot( await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd)) await compareOrRefreshGolden(DAMAGED_EXPECTED, snapshot, MODE) // Both damage shapes surface as marked, unselectable, uncopyable cards // that still carry their metadata and the discovery-reported reason. - expect(snapshot).toContain('已损坏: broken-yaml') - expect(snapshot).toContain('已损坏: 幽灵预设') + expect(snapshot).toContain('加载失败: broken-yaml') + expect(snapshot).toContain('加载失败: 幽灵预设') expect(snapshot).toContain('not valid YAML') expect(snapshot).toContain('agent.cordis.yml is missing') - expect(await dialog.getByRole('button', { name: '已损坏: broken-yaml' }).isDisabled()).toBe(true) + expect(await dialog.getByRole('button', { name: '加载失败: broken-yaml' }).isDisabled()).toBe(true) expect(await dialog.getByRole('button', { name: '复制: 幽灵预设' }).isDisabled()).toBe(true) // A broken card offers no "set default" affordance at all — the aria name // IS the broken marking, so the picking name must not exist. diff --git a/apps/web/tests/background-task-list.e2e.ts b/apps/web/tests/background-task-list.e2e.ts new file mode 100644 index 0000000000..734d43131a --- /dev/null +++ b/apps/web/tests/background-task-list.e2e.ts @@ -0,0 +1,133 @@ +// Web e2e scenario: the session-header background-task list over the real +// host. No model call is involved — a genuine `run_in_background` bash call +// registers with `ctx.tasks`, and the assertion chain is the whole delivery +// path: registry change feed → api-proxy `session/tasks` frame → the client's +// `tasksBySession` mirror → the header action. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import type { Agent } from '@deepseek-ai/dsh-agent' +import { CallId } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import { TaskId } from '@deepseek-ai/dsh-tasks' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, + launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { newEnglishPage, saveFailureShot } from './support.ts' + +const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url)) +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/background-task-list', import.meta.url)) +const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md') +const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md') +const MODE = webSnapshotMode() +const SEED_ID = 'background-task-list-web-e2e' +// Long enough that the running assertions never race the process exiting on +// their own; the test kills it explicitly to reach the settled state. +const COMMAND = 'sleep 45' + +/** + * Wait for the Host to publish the live Agent that opening a session resumes. + * @param scaffold - the booted web scaffold. + * @param sessionId - the opened session's identity. + * @returns the registered Agent instance. + */ +async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise { + const deadline = Date.now() + 30_000 + for (;;) { + const found = scaffold.ctx.agents.get(sessionId) + if (found !== undefined) return found + if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`) + await new Promise(resolve => setTimeout(resolve, 100)) + } +} + +describe.skipIf(MODE === 'record')('web e2e: background task list', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + let agent: Agent + let taskId: TaskId + + beforeAll(async () => { + scaffold = await launchWebScaffold({}) + await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + + const groupRow = page.locator('[role="treeitem"]').first() + await groupRow.waitFor({ timeout: 15_000 }) + await groupRow.click() + const sessionRow = page.locator('[role="treeitem"]').nth(1) + await sessionRow.waitFor({ timeout: 10_000 }) + await sessionRow.click() + + // Opening the session drives the Host's ordinary Agent resolution; the + // task owner must be that exact live instance, never a second one. + // `expect.poll` is test-scoped, so this hook polls by hand. + agent = await liveAgent(scaffold, SessionId(SEED_ID)) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('shows a running background task in the session header without a refresh', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-running')) + // Point assertion, not a poll: `expect.poll` retries until a predicate + // holds, so polling for zero passes at t=0 and proves nothing. The + // "renders nothing without a task" branch is owned by the component suite. + const trigger = page.getByRole('button', { name: '1 background task running' }) + expect(await trigger.count()).toBe(0) + + const started = await scaffold.ctx.tools.execute({ + signal: new AbortController().signal, + callId: CallId('background-task-list-e2e'), + name: 'bash', + arguments: { command: COMMAND, description: 'Hold a background slot open', run_in_background: true }, + agent, + }) + const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('') + const matched = /\bbash-\d+\b/.exec(reported) + if (matched === null) throw new Error(`background bash reported no task id: ${reported}`) + taskId = TaskId(matched[0]) + + await trigger.waitFor({ timeout: 15_000 }) + await trigger.click() + const row = page.getByRole('list', { name: 'Background tasks' }).getByRole('listitem').first() + await row.waitFor({ timeout: 10_000 }) + await expect.poll(() => row.textContent()).toContain(COMMAND) + + const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('flips the open list to the cancelled outcome when the registry settles it', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-settled')) + expect(scaffold.ctx.tasks.kill(taskId, agent, 'web e2e cancellation')).toBe('requested') + + // The trigger drops its live count once the task leaves running/stopping, + // which is also the proof that settlement reached the browser unprompted. + const idle = page.getByRole('button', { name: '1 background task' }) + await idle.waitFor({ timeout: 20_000 }) + + const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE) + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it('keeps its snapshot inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md']) + }) +}) diff --git a/apps/web/tests/core-web-profile.snapshot.ts b/apps/web/tests/core-web-profile.snapshot.ts deleted file mode 100644 index 1178390837..0000000000 --- a/apps/web/tests/core-web-profile.snapshot.ts +++ /dev/null @@ -1,137 +0,0 @@ -import { writeFile } from 'node:fs/promises' -import { join } from 'node:path' -import { fileURLToPath } from 'node:url' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import type { AgentHandle } from '@deepseek-ai/dsh-agent' -import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' -import { SessionId } from '@deepseek-ai/dsh-session' -import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts' - -const CORE_WEB_OVERLAY = fileURLToPath(new URL('../../cli/config/core-web.cordis.yml', import.meta.url)) -const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/core-web-profile', import.meta.url)) -const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') -const PROMPT = 'Reply exactly CORE_WEB_REQUEST_OK and stop.' - -describe('core Web profile', () => { - let scaffold: WebScaffold - let agentHandle: AgentHandle - - beforeAll(async () => { - const systemPrompt = process.env.DSH_SYSTEM_PROMPT - Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT') - try { - scaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE }) - } finally { - if (systemPrompt !== undefined) process.env.DSH_SYSTEM_PROMPT = systemPrompt - } - agentHandle = await scaffold.ctx.agents.create({ - sessionId: SessionId('core-web-profile-smoke'), - meta: { cwd: scaffold.workspaceCwd }, - agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }) - }) - - afterAll(async () => { - const failures: unknown[] = [] - await agentHandle?.dispose().catch((error: unknown) => failures.push(error)) - await scaffold?.close().catch((error: unknown) => failures.push(error)) - if (failures.length === 1) throw failures[0] - if (failures.length > 1) throw new AggregateError(failures, 'core Web profile smoke teardown failed') - }) - - it('sends the RL prompt and tool schemas through a real request, then executes both tools', async () => { - agentHandle.agent.followup(createUserMessage({ - content: [{ type: 'text', text: PROMPT }], - source: { kind: 'user' }, - })) - await agentHandle.agent.whenIdle() - - const requestHeader = agentHandle.agent.session.requestHeader() - if (requestHeader === undefined) throw new Error('the core Web agent issued no model request') - - const seedPath = join(scaffold.workspaceCwd, 'profile-smoke.txt') - await writeFile(seedPath, 'CORE_WEB_EDITOR_OK\n') - const signal = new AbortController().signal - const bash = await scaffold.ctx.tools.execute({ - signal, - callId: CallId('core-web-bash-smoke'), - name: 'bash', - arguments: { command: "printf 'CORE_WEB_BASH_OK\\n'" }, - agent: agentHandle.agent, - }) - const editor = await scaffold.ctx.tools.execute({ - signal, - callId: CallId('core-web-editor-smoke'), - name: 'str_replace_editor', - arguments: { command: 'view', path: seedPath }, - agent: agentHandle.agent, - }) - - const text = (result: typeof bash): string => result.content - .filter(block => block.type === 'text') - .map(block => block.text) - .join('') - .replaceAll(scaffold.workspaceCwd, '{{cwd}}') - .trimEnd() - - expect({ - prompt: requestHeader.system, - tools: requestHeader.tools?.map(tool => tool.name), - bash: text(bash), - editor: text(editor), - }).toMatchInlineSnapshot(` - { - "bash": "CORE_WEB_BASH_OK", - "editor": "Here's the content of {{cwd}}/profile-smoke.txt with line numbers (which has a total of 2 lines): - 1 CORE_WEB_EDITOR_OK - 2", - "prompt": "You are a helpful software engineer assistant.", - "tools": [ - "bash", - "str_replace_editor", - ], - } - `) - expect(requestHeader.tools).toEqual(scaffold.ctx.tools.schemas(agentHandle.agent)) - - const entries = [...scaffold.ctx.loader.entries()] - expect(entries.find(entry => entry.options.id === 'persistent-bash')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'pty-local')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'str-replace-editor')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'web-runtime')?.fiber).toBeDefined() - expect(entries.find(entry => entry.options.id === 'workspace-context')?.fiber).toBeUndefined() - await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl']) - }) - - it('uses DSH_SYSTEM_PROMPT as the complete prompt when configured', async () => { - const previous = process.env.DSH_SYSTEM_PROMPT - process.env.DSH_SYSTEM_PROMPT = 'RL prompt override' - let overrideScaffold: WebScaffold | undefined - let overrideAgent: AgentHandle | undefined - try { - overrideScaffold = await launchWebScaffold({ extraOverlayPath: CORE_WEB_OVERLAY, replayFixture: FIXTURE }) - overrideAgent = await overrideScaffold.ctx.agents.create({ - sessionId: SessionId('core-web-profile-override'), - meta: { cwd: overrideScaffold.workspaceCwd }, - agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, - }) - overrideAgent.agent.followup(createUserMessage({ - content: [{ type: 'text', text: PROMPT }], - source: { kind: 'user' }, - })) - await overrideAgent.agent.whenIdle() - expect(overrideAgent.agent.session.requestHeader()?.system).toBe('RL prompt override') - } finally { - try { - await overrideAgent?.dispose() - } finally { - try { - await overrideScaffold?.close() - } finally { - if (previous === undefined) Reflect.deleteProperty(process.env, 'DSH_SYSTEM_PROMPT') - else process.env.DSH_SYSTEM_PROMPT = previous - } - } - } - }) -}) diff --git a/apps/web/tests/feedback-command.e2e.ts b/apps/web/tests/feedback-command.e2e.ts new file mode 100644 index 0000000000..577e77a97a --- /dev/null +++ b/apps/web/tests/feedback-command.e2e.ts @@ -0,0 +1,101 @@ +// Keyless assembled-browser coverage for the /feedback command over the +// shipped Web bundles and the real host wire. The command plane settles +// without a model turn: the host appends the log-only command/run + +// feedback/record + command/done lifecycle, and the transcript renders the +// acknowledgement — the recorded session id plus the session-sharing +// disclosure — as a persistent command row. The scaffold mounts the shipped +// telemetry row in FULL mode against a local dead endpoint (no record leaves +// the process), so the golden pins the shipped default sentence +// `Session sharing is enabled.`; the per-status sentences are pinned by the +// package and OTel unit tests. +import { readFile } from 'node:fs/promises' +import { fileURLToPath } from 'node:url' +import { join } from 'node:path' +import type { Browser, Page } from 'playwright' +import { chromium } from 'playwright' +import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' +import { + assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts, + launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold, +} from './scaffold.ts' +import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/feedback-command', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md') +const MODE = webSnapshotMode() +// Discard port: loopback listener never binds, so FULL telemetry discloses +// the shipped default policy without any record reaching a collector. +const TELEMETRY_URL = 'http://127.0.0.1:9/v1/logs' + +const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.' + +describe('web e2e: /feedback command acknowledgement', () => { + let scaffold: WebScaffold + let browser: Browser + let page: Page + let tripwire: ReturnType + + beforeAll(async () => { + scaffold = await launchWebScaffold({ + telemetryUrl: TELEMETRY_URL, + ...(MODE === 'record' ? {} : { replayFixture: FIXTURE }), + }) + browser = await chromium.launch() + page = await newEnglishPage(browser) + tripwire = watchConsole(page) + await page.goto(scaffold.baseUrl, { waitUntil: 'load' }) + await page.waitForSelector('[class*="frame"]', { timeout: 30_000 }) + // Fresh world: connecting a workspace births the blank session whose + // live composer accepts the slash line. + await connectFreshWorkspace(page, scaffold.workspaceCwd) + }, 120_000) + + afterAll(async () => { + await browser?.close() + await scaffold?.close() + }) + + it('drives the recorded prompt to a settled turn (all modes)', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-drive')) + if (MODE !== 'record') { + // Drift guard: the committed fixture must carry exactly the drive prompt. + expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT]) + } + const input = page.locator('textarea').first() + await input.waitFor({ timeout: 10_000 }) + // Arm the turn-boundary waiter BEFORE sending, so a burst replay cannot + // miss the turn/end that settles the recorded turn. + const settled = scaffold.whenTurnSettled() + await input.fill(PROMPT) + await input.press('Enter') + const sessionId = await settled + if (MODE === 'record') { + await recordFixture(scaffold, sessionId, FIXTURE) + } + }, 60_000) + + it.skipIf(MODE === 'record')('records feedback and renders the acknowledgement with session id and sharing status', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command')) + // The drive test settled the recorded turn: the transcript is active (a + // command row does not render while a fresh session is still blank) and + // the replayed reply is on screen. + await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 }) + const input = page.locator('textarea').first() + await input.fill('/feedback the diff view is unreadable') + await input.press('Enter') + // The command plane settles without a model turn: the ack row names the + // recorded session and the mounted FULL backend's disclosure. + await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 }) + expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1) + const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd) + await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE) + + expect(tripwire.pageErrors).toEqual([]) + expect(tripwire.warnings).toEqual([]) + }, 60_000) + + it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => { + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md']) + }) +}) diff --git a/apps/web/tests/hmr-live.e2e.ts b/apps/web/tests/hmr-live.e2e.ts index cafd0fb474..57a3867187 100644 --- a/apps/web/tests/hmr-live.e2e.ts +++ b/apps/web/tests/hmr-live.e2e.ts @@ -6,8 +6,8 @@ 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 { Context } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { REPO_ROOT } from './support.ts' diff --git a/apps/web/tests/math-rendering.e2e.ts b/apps/web/tests/math-rendering.e2e.ts index b183fe7df5..de24c1ca76 100644 --- a/apps/web/tests/math-rendering.e2e.ts +++ b/apps/web/tests/math-rendering.e2e.ts @@ -119,6 +119,10 @@ describe('web e2e: settled Markdown math rendering', () => { await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6) await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2) expect(await page.locator('.katex-error').count()).toBe(0) + await expect.poll( + () => page.getByText('Input 0 tok · Output 0 tok', { exact: false }).count(), + { timeout: 10_000 }, + ).toBe(1) const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) .split(SEED_ID).join('{{seededId}}') diff --git a/apps/web/tests/message-actions.e2e.ts b/apps/web/tests/message-actions.e2e.ts index 7555be866b..4284d3d71a 100644 --- a/apps/web/tests/message-actions.e2e.ts +++ b/apps/web/tests/message-actions.e2e.ts @@ -128,6 +128,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria')) await page.getByRole('button', { name: /^Select model, current/ }) .waitFor({ timeout: 10_000 }) + await page.getByText(/Cache hit \d+%/u).first().waitFor({ timeout: 10_000 }) // Keep a footer focused so opacity-hidden actions stay in the a11y tree // as an active/focused control during the capture. await page.getByRole('button', { name: 'Copy' }).first().focus() diff --git a/apps/web/tests/message-feedback-protocol.snapshot.ts b/apps/web/tests/message-feedback-protocol.snapshot.ts new file mode 100644 index 0000000000..9fbc556f77 --- /dev/null +++ b/apps/web/tests/message-feedback-protocol.snapshot.ts @@ -0,0 +1,115 @@ +import { readFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import { + assertFixtureInventory, + compareOrRefreshGolden, + launchWebScaffold, + seedSession, + type WebScaffold, +} from './scaffold.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-protocol', import.meta.url)) +const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json') +const SESSION_ID = 'message-feedback-protocol' +const MESSAGE_ID = '11111111-1111-4111-8111-111111111111' + +interface ProtocolExchange { + readonly endpoint: string + readonly request: unknown + readonly status: number + readonly response: unknown +} + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null +} + +/** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */ +function createdVersion(response: unknown): string { + if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true + || !isRecord(response.result.value) || response.result.value.ok !== true + || !isRecord(response.result.value.value) + || typeof response.result.value.value.version !== 'string') { + throw new Error('messageFeedback.put did not return a successful versioned item') + } + return response.result.value.value.version +} + +/** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */ +function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string { + return JSON.stringify(exchanges, (key, value: unknown) => { + if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}' + if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}' + return value + }, 2) +} + +describe('message feedback Host Remote protocol', () => { + let scaffold: WebScaffold + + beforeAll(async () => { + scaffold = await launchWebScaffold() + await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID) + }) + + afterAll(async () => { + await scaffold?.close() + }) + + it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => { + const exchanges: ProtocolExchange[] = [] + const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise => { + const payload = { args: { request } } + const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + type: 'client-request', + rpcId, + method: endpoint, + payload, + }), + }) + const body: unknown = await response.json() + exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body }) + return body + } + + await invoke('feedback-invalid', 'messageFeedback/put', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + rating: 'invalid-rating', + ifVersion: null, + }) + await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID }) + const created = await invoke('feedback-put', 'messageFeedback/put', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + rating: 'positive', + note: 'Useful answer', + ifVersion: null, + }) + const version = createdVersion(created) + expect(version).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/) + await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID }) + await invoke('feedback-conflict', 'messageFeedback/put', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + rating: 'negative', + ifVersion: null, + }) + await invoke('feedback-delete', 'messageFeedback/delete', { + sessionId: SESSION_ID, + messageId: MESSAGE_ID, + ifVersion: version, + }) + await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID }) + + expect(exchanges.every(exchange => exchange.status === 200)).toBe(true) + await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode) + await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.jsonl']) + }) +}) diff --git a/apps/web/tests/minimal-preset.snapshot.ts b/apps/web/tests/minimal-preset.snapshot.ts new file mode 100644 index 0000000000..0c8c6fa765 --- /dev/null +++ b/apps/web/tests/minimal-preset.snapshot.ts @@ -0,0 +1,115 @@ +import { mkdir, writeFile } from 'node:fs/promises' +import { join } from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterAll, beforeAll, describe, expect, it } from 'vitest' +import type { AgentHandle } from '@deepseek-ai/dsh-agent' +import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' +import { SessionId } from '@deepseek-ai/dsh-session' +import type {} from '@deepseek-ai/dsh-agent-presets' +import type {} from '@deepseek-ai/dsh-system-prompt' +import { assertFixtureInventory, launchWebScaffold, type WebScaffold } from './scaffold.ts' + +const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/minimal-preset', import.meta.url)) +const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl') +const PROMPT = 'Reply exactly MINIMAL_PRESET_REQUEST_OK and stop.' + +describe('minimal agent preset', () => { + let scaffold: WebScaffold + let agentHandle: AgentHandle + let disposeInjectedPrompt: () => void + + beforeAll(async () => { + scaffold = await launchWebScaffold({ replayFixture: FIXTURE }) + disposeInjectedPrompt = scaffold.ctx.systemPrompt.section({ + name: 'test:injected-prompt', + order: 999, + text: 'THIS TEXT MUST NOT REACH THE MODEL.', + }) + agentHandle = await scaffold.ctx.agents.create({ + sessionId: SessionId('minimal-preset-smoke'), + meta: { cwd: scaffold.workspaceCwd, agentPreset: 'minimal' }, + agentOptions: { provider: 'deepseek-official', model: 'deepseek-v4-flash' }, + setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx, 'minimal').then(() => undefined), + }) + }) + + afterAll(async () => { + const failures: unknown[] = [] + await agentHandle?.dispose().catch((error: unknown) => failures.push(error)) + try { + disposeInjectedPrompt?.() + } catch (error: unknown) { + failures.push(error) + } + await scaffold?.close().catch((error: unknown) => failures.push(error)) + if (failures.length === 1) throw failures[0] + if (failures.length > 1) throw new AggregateError(failures, 'minimal preset smoke teardown failed') + }) + + it('sends the exact RL prompt and schemas, then executes the persistent shell and editor', async () => { + agentHandle.agent.followup(createUserMessage({ + content: [{ type: 'text', text: PROMPT }], + source: { kind: 'user' }, + })) + await agentHandle.agent.whenIdle() + + const requestHeader = agentHandle.agent.session.requestHeader() + if (requestHeader === undefined) throw new Error('the minimal agent issued no model request') + + const stateDir = join(scaffold.workspaceCwd, 'persistent-state') + await mkdir(stateDir) + const signal = new AbortController().signal + await scaffold.ctx.tools.execute({ + signal, + callId: CallId('minimal-bash-state-setup'), + name: 'bash', + arguments: { command: `cd ${JSON.stringify(stateDir)} && export DSH_MINIMAL_STATE=PERSISTED` }, + agent: agentHandle.agent, + }) + const bash = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('minimal-bash-state-read'), + name: 'bash', + arguments: { command: 'printf \'%s:%s\n\' "$DSH_MINIMAL_STATE" "$PWD"' }, + agent: agentHandle.agent, + }) + const seedPath = join(scaffold.workspaceCwd, 'preset-smoke.txt') + await writeFile(seedPath, 'MINIMAL_EDITOR_OK\n') + const editor = await scaffold.ctx.tools.execute({ + signal, + callId: CallId('minimal-editor-smoke'), + name: 'str_replace_editor', + arguments: { command: 'view', path: seedPath }, + agent: agentHandle.agent, + }) + + const text = (result: typeof bash): string => result.content + .filter(block => block.type === 'text') + .map(block => block.text) + .join('') + .replaceAll(scaffold.workspaceCwd, '{{cwd}}') + .trimEnd() + + expect({ + prompt: requestHeader.system, + tools: requestHeader.tools?.map(tool => tool.name), + bash: text(bash), + editor: text(editor), + }).toMatchInlineSnapshot(` + { + "bash": "PERSISTED:{{cwd}}/persistent-state", + "editor": "Here's the content of {{cwd}}/preset-smoke.txt with line numbers (which has a total of 2 lines): + 1 MINIMAL_EDITOR_OK + 2", + "prompt": "You are a helpful software engineer assistant.", + "tools": [ + "bash", + "str_replace_editor", + ], + } + `) + expect(requestHeader.tools?.toSorted((left, right) => left.name.localeCompare(right.name))) + .toEqual(scaffold.ctx.tools.schemas(agentHandle.agent).toSorted((left, right) => left.name.localeCompare(right.name))) + await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl']) + }) +}) diff --git a/apps/web/tests/navigation-panes.e2e.ts b/apps/web/tests/navigation-panes.e2e.ts index b96a1fa393..c8b6455296 100644 --- a/apps/web/tests/navigation-panes.e2e.ts +++ b/apps/web/tests/navigation-panes.e2e.ts @@ -11,6 +11,7 @@ import { fileURLToPath } from 'node:url' import { join } from 'node:path' import type { Browser, Page, Response } from 'playwright' import { chromium } from 'playwright' +import { strFromU8, unzipSync } from 'fflate' import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed } from 'vitest' import { parseSessionLog } from '@deepseek-ai/dsh-llm-replay' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -274,6 +275,23 @@ describe('web e2e: navigation & panes over a rich seeded session', () => { await details.getByRole('button', { name: 'Close details' }).click() }, 60_000) + it.skipIf(MODE === 'record')('downloads the session-log ZIP from the trajectory toolbar', async () => { + onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-export')) + await ensureSeedOpen(page) + await page.getByRole('tab', { name: 'Trajectory' }).click() + const downloadPromise = page.waitForEvent('download', { timeout: 30_000 }) + await page.getByRole('button', { name: 'Export session log' }).click() + const download = await downloadPromise + expect(download.suggestedFilename()).toMatch(/^dsh-session-.+\.zip$/) + // The real host streamed the ZIP; its root entry is the persisted log + // text verbatim (the assembled seam: real route, real persistence read). + const files = unzipSync(await readFile(await download.path())) + expect(Object.keys(files)).toEqual(['session.jsonl']) + const content = strFromU8(files['session.jsonl'] as Uint8Array) + expect(content.split('\n')[0]).toContain(SEED_ID) + expect(content).toContain('FIRST_DONE') + }, 60_000) + it.skipIf(MODE === 'record')('focuses the ledger by dragging an overview interval', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-navigation-timeline')) await ensureSeedOpen(page) diff --git a/apps/web/tests/pwa-manifest.e2e.ts b/apps/web/tests/pwa-manifest.e2e.ts index 696e1c7797..fe97e42da9 100644 --- a/apps/web/tests/pwa-manifest.e2e.ts +++ b/apps/web/tests/pwa-manifest.e2e.ts @@ -25,3 +25,11 @@ it('ships install metadata with the built web application', async () => { }], }) }) + +it('ships a favicon that switches to a light mark under dark color scheme', async () => { + const favicon = await readFile(join(DIST_ROOT, 'favicon.svg'), 'utf8') + // The light fill must live inside the dark-scheme media query, so the icon + // stays black in light mode and only turns white under a dark scheme. + expect(favicon).toMatch(/@media \(prefers-color-scheme: dark\)\s*{\s*path\s*{[^}]*fill:\s*#fff/i) + expect(favicon).toContain('fill="#000"') +}) diff --git a/apps/web/tests/scaffold.ts b/apps/web/tests/scaffold.ts index 945775678f..772bd4ae91 100644 --- a/apps/web/tests/scaffold.ts +++ b/apps/web/tests/scaffold.ts @@ -29,13 +29,12 @@ import { join } from 'node:path' import { pathToFileURL } from 'node:url' import type { Page } from 'playwright' import { expect } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include, { type PatchOptions } from '@cordisjs/plugin-include' -import Group from '@cordisjs/plugin-group' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import Group from '@deepseek-ai/cordis-plugin-group' import { scrubRequestHeaders, stabilizeFixtureMessageIds } from '@deepseek-ai/dsh-acp-snapshot' import { - addHarnessSourceSection, assertEntriesLoaded, composeEntries, healProfilesModuleFallback, @@ -65,6 +64,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 { provideCmdline } from '@deepseek-ai/dsh-cmdline' import { REPO_ROOT, requireDist } from './support.ts' /** Snapshot mode for the lane, from $DSH_SNAPSHOT (same vocabulary as the other snapshot suites). */ @@ -245,6 +245,13 @@ export interface LaunchOptions { } /** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */ welcomeNoticePending?: boolean + /** + * Mount the shipped telemetry row in FULL mode against this exporter URL + * instead of disabling it. Used to pin a real backend disclosure in + * assembled coverage; point the URL at a local dead endpoint so no record + * leaves the process. + */ + telemetryUrl?: string /** * Browse through a trusted non-loopback hostname that the browser resolves * to loopback (for example `*.localhost`). The test server stays bound to @@ -334,6 +341,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise failures.push(cleanupError)) + restoreSkillRootEnvironment() if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed') throw error } @@ -395,8 +403,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise { + throw new Error(`web e2e scaffold: the web app requested exit ${String(code)} with no arguments to reject`) + }, + }) await ctx.plugin(Loader) ctx.loader.builtins.include = Include // `cordis:group` beside it, exactly as `boot()` registers it: a group row is // how a preset gives one `isolate` realm to a provider and its consumers, // and a preset resolving package names from its own directory cannot reach - // `@cordisjs/plugin-group` by name. + // `@deepseek-ai/cordis-plugin-group` by name. ctx.loader.builtins.group = Group // The shipped CLI deliberately has no dependency on this opt-in package. // Keep the Loader row real without broadening the product installation. if (options.cordisTools === true) ctx.loader.builtins['tool-cordis'] = ToolCordis - if (surfaceContext) { - ctx.inject(['systemPrompt'], (promptCtx) => { addHarnessSourceSection(promptCtx, REPO_ROOT) }) - } await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(rootConfig).href, patches }, diff --git a/apps/web/tests/seeded-history.e2e.ts b/apps/web/tests/seeded-history.e2e.ts index a112933f9c..9ec978fb97 100644 --- a/apps/web/tests/seeded-history.e2e.ts +++ b/apps/web/tests/seeded-history.e2e.ts @@ -18,7 +18,6 @@ import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message } from '@deepseek-ai/dsh-llm' import { deriveEventMessage, SessionId } from '@deepseek-ai/dsh-session' -import type {} from '@deepseek-ai/dsh-agent-presets' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' import { join } from 'node:path' @@ -195,21 +194,11 @@ describe('web e2e: seeded history renders through cold resume', () => { if (MODE !== 'record') { const raw = await readFile(SEED, 'utf8') expect(fixtureUserPrompts(raw), 'seed fixture must carry exactly the drive prompt').toEqual([PROMPT]) - // The meter belongs to an agent's preset, not to the process — token - // accounting is per session. It is used here as a pure pricing function - // over fixture content, so a throwaway composition is enough to reach one. - const priced = await scaffold.ctx.agents.create({ - sessionId: SessionId('seeded-history-pricing'), - setup: agentCtx => scaffold.ctx.agentPresets.mount(agentCtx).then(() => undefined), - }) - let realizedWithCompaction: string - try { - const meter = scaffold.ctx.agentPresets.serviceFor(priced.agent, 'tokenMeter') - if (meter === undefined) throw new Error('seeded-history requires the composed token meter') - realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter) - } finally { - await priced.dispose() - } + // The meter is host-plane — it takes no configuration and keys every + // fold by Session — so pricing fixture content needs no agent at all. + const meter = scaffold.ctx.get('tokenMeter') + if (meter === undefined) throw new Error('seeded-history requires the host token meter') + const realizedWithCompaction = withCompaction(realizeSeedFixture(scaffold, raw, SEED_ID), meter) await seedSession(scaffold, realizedWithCompaction, SEED_ID) } browser = await chromium.launch() @@ -468,9 +457,9 @@ describe('web e2e: seeded history renders through cold resume', () => { if (done?.type !== 'command/done') throw new Error('feedback command did not settle') const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? [] expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`) - expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i) + expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\./i) expect(extraLine).toBeUndefined() - const userId = userLine?.slice('User: '.length) + const userId = userLine?.match(/^User: ([0-9a-f-]+)/i)?.[1] if (userId === undefined) throw new Error('feedback command omitted the user id') const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index faf01ede62..ae0bab04c8 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -36,9 +36,9 @@ const EXPECTED_TOOLS = [ 'list_agents', 'ralph', 'read', + 'read_image', 'send_message', 'skill', - 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill', diff --git a/apps/web/tests/smoke-real.e2e.ts b/apps/web/tests/smoke-real.e2e.ts index b0acf074b4..24a6f269c3 100644 --- a/apps/web/tests/smoke-real.e2e.ts +++ b/apps/web/tests/smoke-real.e2e.ts @@ -478,17 +478,20 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke requireDist() sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-')) const port = await probeFreePort() - // tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate + // tsx boot mirrors the runtime half of the root dsh script. Isolate // the host-level Harness and shared-agent homes inside the temp world; tsx // also needs the repo's loader and tsconfig paths pointed at explicitly. const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href child = spawn( process.execPath, [ - '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', '--port', String(port), + '--import', tsxLoader, join(REPO_ROOT, 'apps/cli/src/bin.ts'), 'web', + // Launcher flags come first: the first token the launcher does not own + // starts the web app's own arguments. // Pin the in-browser picker: the shipped `-auto` row would resolve to // the native OS chooser on this bind, and no page can drive that. '--patch', fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)), + '--port', String(port), ], { cwd: sessionsDir, diff --git a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md index 395e517aa1..0ddc4dd77b 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/created.expected.md @@ -40,7 +40,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 + - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 - code: minimal - 'button "查看: 极简模式"': - img @@ -62,7 +62,7 @@ - list: - listitem: - 'button "设为默认: 我的模式"': - - text: 我的模式 自定义 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 + - text: 我的模式 自定义 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 - code: my-agent - 'button "查看路径: 我的模式"': - img diff --git a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md index b40b848b5e..f853cacf24 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/damaged.expected.md @@ -40,7 +40,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 + - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 - code: minimal - 'button "查看: 极简模式"': - img @@ -61,8 +61,8 @@ - heading "自定义" [level=3] - list: - listitem: - - 'button "已损坏: broken-yaml" [disabled]': - - text: broken-yaml 已损坏 自定义 暂无描述。 + - 'button "加载失败: broken-yaml" [disabled]': + - text: broken-yaml 加载失败 自定义 暂无描述。 - alert: "the composition is not valid YAML: unexpected end of the stream within a flow collection (3:1)" - code: broken-yaml - 'button "查看路径: broken-yaml"': @@ -70,13 +70,13 @@ - text: 查看路径 - 'button "复制: broken-yaml" [disabled]': - img - - text: 预设已损坏,无法复制 + - text: 预设加载失败,不能复制 - 'button "删除: broken-yaml"': - img - text: 删除 - listitem: - - 'button "已损坏: 幽灵预设" [disabled]': - - text: 幽灵预设 已损坏 自定义 composition 已被手动删除。 + - 'button "加载失败: 幽灵预设" [disabled]': + - text: 幽灵预设 加载失败 自定义 composition 已被手动删除。 - alert: the composition file agent.cordis.yml is missing — the directory still occupies the id; delete it or restore the file - code: ghost - 'button "查看路径: 幽灵预设"': @@ -84,7 +84,7 @@ - text: 查看路径 - 'button "复制: 幽灵预设" [disabled]': - img - - text: 预设已损坏,无法复制 + - text: 预设加载失败,不能复制 - 'button "删除: 幽灵预设"': - img - text: 删除 diff --git a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md index dcbe72641c..ad2bf86389 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -40,7 +40,7 @@ - text: 复制 - listitem: - 'button "设为默认: 极简模式"': - - text: 极简模式 内置 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 + - text: 极简模式 内置 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 - code: minimal - 'button "查看: 极简模式"': - img @@ -58,6 +58,7 @@ - 'button "复制: 创造模式"': - img - text: 复制 + - heading "自定义" [level=3] - button "用「创造模式」创作自定义预设": - img - text: 用「创造模式」创作自定义预设 diff --git a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md index 78ec056f56..7e0d1ae032 100644 --- a/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md +++ b/apps/web/tests/snapshots/agent-preset-selection/menu.expected.md @@ -3,5 +3,5 @@ - text: Standard mode Full coding agent with file editing, shell, file and web search, skills, planning, goals, subagents, and workflows. - img - menuitem "Code mode All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program." - - menuitem "Minimal mode Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions." + - menuitem "Minimal mode Two-tool coding agent with persistent bash and str_replace_editor." - menuitem "Creator mode Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance." diff --git a/apps/web/tests/snapshots/background-task-list/running.expected.md b/apps/web/tests/snapshots/background-task-list/running.expected.md new file mode 100644 index 0000000000..1adaa965e2 --- /dev/null +++ b/apps/web/tests/snapshots/background-task-list/running.expected.md @@ -0,0 +1,2 @@ +- list "Background tasks": + - listitem: bash sleep 45 running {{duration}} diff --git a/apps/web/tests/snapshots/background-task-list/settled.expected.md b/apps/web/tests/snapshots/background-task-list/settled.expected.md new file mode 100644 index 0000000000..c97ff5026c --- /dev/null +++ b/apps/web/tests/snapshots/background-task-list/settled.expected.md @@ -0,0 +1,2 @@ +- list "Background tasks": + - listitem: "bash sleep 45 signal: SIGTERM {{duration}}" diff --git a/apps/web/tests/snapshots/feedback-command/ack.expected.md b/apps/web/tests/snapshots/feedback-command/ack.expected.md new file mode 100644 index 0000000000..89d40acb3b --- /dev/null +++ b/apps/web/tests/snapshots/feedback-command/ack.expected.md @@ -0,0 +1,39 @@ +- banner: + - navigation "Session hierarchy": + - button "Reply with the single word" [disabled] + - img + - text: Standard mode + - tablist: + - tab "Chat" [selected] + - tab "Trajectory" +- text: Reply with the single word LIGHTHOUSE and stop. {{clock}} +- button "Copy": + - img +- button "Context injection @deepseek-ai/dsh-system-prompt": + - img + - img + - text: Context injection @deepseek-ai/dsh-system-prompt +- button "Think The user wants me to reply with a single word. Let me comply.": + - img + - img + - text: Think The user wants me to reply with a single word. Let me comply. +- paragraph: LIGHTHOUSE +- button "Copy": + - img +- button "Branch into a new conversation": + - img +- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s +- 'button "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."': + - img + - img + - text: "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled." +- textbox "Message the agent" +- button "Commands": + - img +- 'button "Access mode, current: Workspace Write"': Workspace Write +- button "Select model, current DeepSeek-V4-Flash": + - text: DeepSeek-V4-Flash + - img +- button "6% of context used" +- button "Send message" [disabled] +- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok diff --git a/apps/web/tests/snapshots/feedback-command/session.jsonl b/apps/web/tests/snapshots/feedback-command/session.jsonl new file mode 100644 index 0000000000..d528f36c0e --- /dev/null +++ b/apps/web/tests/snapshots/feedback-command/session.jsonl @@ -0,0 +1,17 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"} +{"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}} +{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}} +{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}} +{"type":"assistant/chunk","seq":5,"time":1785015039930,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":6,"time0":1785015039930,"data":{"turn":1,"step":1,"index":0,"dt":[162,28,1,0,0,46,1,0,0,0,11,0,0,30],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}} +{"type":"assistant/chunk","seq":21,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":22,"time0":1785015040209,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,30,1],"texts":["L","IGH","TH","O","USE"]}} +{"type":"assistant/chunk","seq":27,"time":1785015040241,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}} +{"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}} +{"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}} +{"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"} +{"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json b/apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json new file mode 100644 index 0000000000..302e4876df --- /dev/null +++ b/apps/web/tests/snapshots/message-feedback-protocol/protocol.expected.json @@ -0,0 +1,203 @@ +[ + { + "endpoint": "/api/messageFeedback/put", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "invalid-rating", + "ifVersion": null + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-invalid", + "result": { + "ok": false, + "error": { + "code": "internal", + "message": "typert gateway: messageFeedback/put: wire field \"request\" failed boundary validation", + "details": {} + } + } + } + }, + { + "endpoint": "/api/messageFeedback/list", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-list-empty", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "items": [] + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/put", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "ifVersion": null + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-put", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "version": "{{version}}", + "createdAt": "{{timestamp}}", + "updatedAt": "{{timestamp}}" + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/list", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-list-created", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "items": [ + { + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "version": "{{version}}", + "createdAt": "{{timestamp}}", + "updatedAt": "{{timestamp}}" + } + ] + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/put", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "negative", + "ifVersion": null + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-conflict", + "result": { + "ok": true, + "value": { + "ok": false, + "error": { + "code": "version-conflict", + "current": { + "messageId": "11111111-1111-4111-8111-111111111111", + "rating": "positive", + "note": "Useful answer", + "version": "{{version}}", + "createdAt": "{{timestamp}}", + "updatedAt": "{{timestamp}}" + } + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/delete", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol", + "messageId": "11111111-1111-4111-8111-111111111111", + "ifVersion": "{{version}}" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-delete", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "absent": true + } + } + } + } + }, + { + "endpoint": "/api/messageFeedback/list", + "request": { + "args": { + "request": { + "sessionId": "message-feedback-protocol" + } + } + }, + "status": 200, + "response": { + "type": "server-response", + "rpcId": "feedback-list-deleted", + "result": { + "ok": true, + "value": { + "ok": true, + "value": { + "items": [] + } + } + } + } + } +] diff --git a/apps/web/tests/snapshots/message-feedback-protocol/session.jsonl b/apps/web/tests/snapshots/message-feedback-protocol/session.jsonl new file mode 100644 index 0000000000..d970fb8cb4 --- /dev/null +++ b/apps/web/tests/snapshots/message-feedback-protocol/session.jsonl @@ -0,0 +1,7 @@ +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786406400000,"cwd":"{{cwd}}"} +{"type":"turn/start","seq":0,"time":1786406400001,"data":{"turn":1}} +{"type":"user/message","seq":1,"time":1786406400002,"data":{"role":"user","content":[{"type":"text","text":"Give one useful answer."}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"} +{"type":"step/start","seq":2,"time":1786406400003,"data":{"turn":1,"step":1}} +{"type":"assistant/message","seq":3,"time":1786406400004,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"A useful answer."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"11111111-1111-4111-8111-111111111111"},"usage":{"inputTokens":4,"outputTokens":4}},"surfaceOp":"append"} +{"type":"step/end","seq":4,"time":1786406400005,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":5,"time":1786406400006,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/apps/web/tests/snapshots/core-web-profile/session.jsonl b/apps/web/tests/snapshots/minimal-preset/session.jsonl similarity index 73% rename from apps/web/tests/snapshots/core-web-profile/session.jsonl rename to apps/web/tests/snapshots/minimal-preset/session.jsonl index 04f0d62d15..49977be802 100644 --- a/apps/web/tests/snapshots/core-web-profile/session.jsonl +++ b/apps/web/tests/snapshots/minimal-preset/session.jsonl @@ -1,7 +1,7 @@ -{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}"} -{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly CORE_WEB_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} +{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785974400000,"cwd":"{{cwd}}","agentPreset":"minimal"} +{"type":"user/message","seq":0,"time":1785974400001,"data":{"content":[{"type":"text","text":"Reply exactly MINIMAL_PRESET_REQUEST_OK and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"} {"type":"assistant/chunk","seq":1,"time":1785974400002,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CORE_WEB_REQUEST_OK"}}} -{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CORE_WEB_REQUEST_OK"}}}} +{"type":"assistant/chunk","seq":2,"time":1785974400003,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"MINIMAL_PRESET_REQUEST_OK"}}} +{"type":"assistant/chunk","seq":3,"time":1785974400004,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"MINIMAL_PRESET_REQUEST_OK"}}}} {"type":"assistant/chunk","seq":4,"time":1785974400005,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}} {"type":"assistant/chunk","seq":5,"time":1785974400006,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} diff --git a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md index a9b5dbb982..3476255bab 100644 --- a/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md +++ b/apps/web/tests/snapshots/navigation-panes/trajectory.expected.md @@ -2,6 +2,7 @@ - button "Use actual duration": Duration - button "Collapse turns": Turns - button "Collapse calls": Calls + - button "Export session log": Export - img - searchbox "Search trajectory" - region "Trajectory timeline": diff --git a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md index 87b763d37c..6928b95777 100644 --- a/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md +++ b/apps/web/tests/snapshots/seeded-history/feedback-row.expected.md @@ -38,10 +38,10 @@ - text: Context injection AGENTS.md - img - text: permission preset read-only -- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" [expanded]': +- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." [expanded]': - img - - text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" -- text: "Feedback recorded for session {{seededId}} User: {{uuid}}" + - text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." +- text: "Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." - textbox "Message the agent" - button "Commands": - img diff --git a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md index cf22f5b566..15ceebba6b 100644 --- a/apps/web/tests/snapshots/subagent-conversation/ui.expected.md +++ b/apps/web/tests/snapshots/subagent-conversation/ui.expected.md @@ -43,7 +43,7 @@ - textbox "Message the agent" - button "Commands": - img -- 'button "Access mode, current: Workspace Write"': Workspace Write +- 'button "Access mode, current: Custom"': Custom - button "6% of context used" - button "Send message" [disabled] - text: 2 turns · 2 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 15.6K tok · Output 158 tok diff --git a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md index 7fe41a532d..8f4554e47b 100644 --- a/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md +++ b/apps/web/tests/snapshots/subagent-interrupt/offline-composer.expected.md @@ -20,6 +20,6 @@ - textbox "Parent session offline; sending is unavailable but you can still stop the run" [disabled] - button "Commands" [disabled]: - img -- 'button "Access mode, current: Workspace Write" [disabled]': Workspace Write +- 'button "Access mode, current: Custom" [disabled]': Custom - button "Stop generating" - button "Send message" [disabled] diff --git a/apps/web/tests/subagent-conversation.e2e.ts b/apps/web/tests/subagent-conversation.e2e.ts index fa33e5cd3d..6756b093d2 100644 --- a/apps/web/tests/subagent-conversation.e2e.ts +++ b/apps/web/tests/subagent-conversation.e2e.ts @@ -395,7 +395,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () = expect([ Math.round(clickAreaBox!.x - treeBox!.x), Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width), - ]).toEqual([5, 5]) + // Menu padding alone insets the rows now that the border is gone. + ]).toEqual([4, 4]) await compareOrRefreshGolden( BRANCHLESS_EXPECTED, await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd), diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 40b9be39ca..ee2a1a1a62 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -57,9 +57,9 @@ export function probeFreePort(): Promise { /** * Drive the hero's workspace picker through the composed directory dialog * until the live composer unlocks. A fresh world has no Workspace, so the boot - * lands in the locked view state (startup auto-selection has nothing to + * lands in the Workspace-trigger view state (startup auto-selection has nothing to * select); every scenario that types into the composer must connect one - * first. With nothing to list, the chip gesture raises the dialog directly — + * first. With nothing to list, activating the textarea raises the dialog directly — * adding a workspace is the picker's only entry. The directory is staged here * and adopted through the path editor, which is idempotent across the repeated * connects a scenario may make; creating a folder from inside the dialog (the @@ -73,7 +73,7 @@ export function probeFreePort(): Promise { */ export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('button', { name: 'Choose workspace' }).click() + await page.getByRole('textbox', { name: 'Choose workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: 'Edit path' }).click() @@ -97,7 +97,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo */ export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise { mkdirSync(join(root, name), { recursive: true }) - await page.getByRole('button', { name: '选择工作区' }).click() + await page.getByRole('textbox', { name: '选择工作区' }).click() const dialog = page.getByRole('dialog', { name: '选择工作区目录' }) await dialog.waitFor({ timeout: 10_000 }) await dialog.getByRole('button', { name: '编辑路径' }).click() diff --git a/apps/web/tests/workspace-management.e2e.ts b/apps/web/tests/workspace-management.e2e.ts index c18a522d30..a435592da5 100644 --- a/apps/web/tests/workspace-management.e2e.ts +++ b/apps/web/tests/workspace-management.e2e.ts @@ -99,6 +99,19 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff } } + /** + * Reveal and click a row action, re-hovering if a projection update replaces + * the row before its hover-only button becomes visible. + */ + async function clickHoverAction(row: Locator, name: string): Promise { + const button = row.getByRole('button', { name }) + await expect.poll(async () => { + await row.hover() + return await button.isVisible() + }, { timeout: 10_000 }).toBe(true) + await button.click() + } + beforeAll(async () => { scaffold = await launchWebScaffold({}) // Seed one cold session (Ungrouped bucket) for the flat view + hover card. @@ -137,10 +150,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff it('renames a workspace over the wire with a duplicate-name pre-check', async () => { onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-rename')) - // The actions button is display:none until its row hovers — hover the - // group row first, then the revealed button becomes actionable. - await page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first().hover() - await page.getByRole('button', { name: 'Workspace actions for alpha-ws' }).click() + const alphaRow = page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first() + await clickHoverAction(alphaRow, 'Workspace actions for alpha-ws') await page.getByRole('menuitem', { name: 'Rename' }).click() const dialog = page.getByRole('dialog', { name: 'Rename workspace' }) await dialog.waitFor({ timeout: 10_000 }) @@ -214,17 +225,19 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff // The header row is wrapped by its HoverCard anchor span, so the section // is the nearest groupSection ancestor, not the immediate parent. const groupSection = groupRow.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]') - if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click() - await expect.poll( - () => groupSection.locator('[role="treeitem"]').count(), - { timeout: 10_000 }, - ).toBeGreaterThanOrEqual(2) + await expect.poll(async () => { + const count = await groupSection.locator('[role="treeitem"]').count() + if (count < 2 && await groupRow.getAttribute('aria-expanded') !== 'true') { + await groupRow.click() + await page.waitForTimeout(50) + } + return await groupSection.locator('[role="treeitem"]').count() + }, { timeout: 10_000 }).toBeGreaterThanOrEqual(2) const seededRow = groupSection.locator('[role="treeitem"]').nth(1) await seededRow.click() await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true') - await groupRow.hover() - await page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).click() + await clickHoverAction(groupRow, `Workspace actions for ${workspace.title}`) await page.getByRole('menuitem', { name: 'Delete workspace' }).click() const dialog = page.getByRole('dialog', { name: 'Delete workspace' }) await dialog.waitFor({ timeout: 10_000 }) @@ -338,8 +351,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered') const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first() - await oldRow.hover() - await page.getByRole('button', { name: `Workspace actions for ${title}` }).click() + await clickHoverAction(oldRow, `Workspace actions for ${title}`) await page.getByRole('menuitem', { name: 'Delete workspace' }).click() await page.getByRole('dialog', { name: 'Delete workspace' }) .getByRole('button', { name: 'Delete workspace' }).click() @@ -509,9 +521,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu')) const sessionRow = await seededSessionRow() // The trigger is display:none until its row hovers. - await sessionRow.hover() const trigger = sessionRow.locator('button[aria-label^="Session actions for "]') - await trigger.click() + const triggerName = await trigger.getAttribute('aria-label') + if (triggerName === null) throw new Error('seeded Session row has no actions label') + await clickHoverAction(sessionRow, triggerName) const item = page.getByRole('menuitem', { name: 'Rename' }) await item.waitFor({ timeout: 5_000 }) // Into the list, then back up to the trigger across the 4px gap below it: @@ -559,8 +572,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff const rowTitle = await sessionRow.locator('[class*="title"]').innerText() // Row menu: hover reveals the actions button; Archive session commits // without a confirmation dialog (non-destructive: log + accounting stay). - await sessionRow.hover() - await sessionRow.getByRole('button', { name: `Session actions for ${rowTitle}` }).click() + await clickHoverAction(sessionRow, `Session actions for ${rowTitle}`) await page.getByRole('menuitem', { name: 'Archive session' }).click() // The row disappears on the archive-set echo; with no other visible // stray, the whole Ungrouped bucket withdraws. diff --git a/apps/web/tsconfig.json b/apps/web/tsconfig.json index da25f0cc59..f96a1f8dd1 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -24,7 +24,8 @@ "exclude": [ "tests/scaffold.ts", "tests/scaffold-hermetic.e2e.ts", - "tests/core-web-profile.snapshot.ts", + "tests/minimal-preset.snapshot.ts", + "tests/message-feedback-protocol.snapshot.ts", "tests/live-interactions.e2e.ts", "tests/question-composer.e2e.ts", "tests/approval-composer.e2e.ts", @@ -64,6 +65,7 @@ "tests/agent-preset-selection.e2e.ts", "tests/agent-preset-authoring.e2e.ts", "tests/shipped-composition.e2e.ts", + "tests/feedback-command.e2e.ts", "tests/startup-auto-selection.e2e.ts", "tests/produced-files.e2e.ts", "tests/produced-file-mentions.e2e.ts", @@ -72,6 +74,7 @@ "tests/subagent-interrupt.e2e.ts", "tests/subagent-interrupt-ui.e2e.ts", "tests/sidebar-subagent-activity.e2e.ts", + "tests/background-task-list.e2e.ts", "tests/bash-abort-row.e2e.ts", "tests/skill-tool-row.e2e.ts", "tests/turn-tail-actions.e2e.ts", diff --git a/apps/web/vite.config.ts b/apps/web/vite.config.ts index a8323cdd78..2600d64acb 100644 --- a/apps/web/vite.config.ts +++ b/apps/web/vite.config.ts @@ -5,8 +5,8 @@ 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`.' + + 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. ' + + 'For client-plugin HMR, run `pnpm 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 { diff --git a/bin/dsh b/bin/dsh deleted file mode 100755 index c578d78e74..0000000000 --- a/bin/dsh +++ /dev/null @@ -1,26 +0,0 @@ -#!/bin/sh -# dsh launcher: runs the apps/cli `dsh` bin FROM SOURCE through the tsx ESM -# hook, so a symlink from anywhere (e.g. ~/.local/bin/dsh) always executes the -# current working tree without a build step. -set -eu - -# Resolve symlink chains without readlink -f (not on every macOS). -script=$0 -while [ -L "$script" ]; do - target=$(readlink "$script") - case $target in - /*) script=$target ;; - *) script=$(dirname "$script")/$target ;; - esac -done -root=$(CDPATH='' cd -- "$(dirname -- "$script")/.." && pwd) - -# The ESM-only tsx hook transforms TypeScript and projects this checkout's -# tsconfig paths into Node resolution (the CJS hook stays off: the graph is -# ESM-only and the CJS resolver costs ~0.4s of startup). Absolute paths keep -# both the hook and the tsconfig anchored to this checkout when the launcher -# runs from any cwd, where bare `tsx/esm` would not resolve. -NODE_USE_ENV_PROXY=1 \ - TSX_TSCONFIG_PATH="$root/tsconfig.json" \ - exec node --import "$root/node_modules/tsx/dist/esm/index.mjs" \ - "$root/apps/cli/src/bin.ts" "$@" diff --git a/docs/api-gateway.i18n.yaml b/docs/api-gateway.i18n.yaml index 09204ccfff..a958569942 100644 --- a/docs/api-gateway.i18n.yaml +++ b/docs/api-gateway.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/api-gateway.md -api-gateway.md: 81cd80893d53212edc74cc85e3e05731fa05f411 -api-gateway.zh.md: 692cf825f619f71e86ae801e04246e9feb4a4c36 +api-gateway.md: 3065f3f10861965b327a4412049878dc1ba7faec +api-gateway.zh.md: aa9b726c33fd9f51fc0b2d2ed95c4c9658662796 diff --git a/docs/api-gateway.md b/docs/api-gateway.md index 81cd80893d..3065f3f108 100644 --- a/docs/api-gateway.md +++ b/docs/api-gateway.md @@ -17,7 +17,7 @@ Services normally extend `GatewayService` so the constructor explicitly binds th ```ts import type { Agent } from '@deepseek-ai/dsh-agent' import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export interface CreateGoalRequest { objective: string @@ -60,7 +60,7 @@ The Client uses concrete functions on ordinary objects, not a JavaScript Proxy. ```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' export const inject = ['remote', 'remote.goals'] @@ -138,16 +138,10 @@ SRC solves only dispatch for a Host process running from source. The Client does ## Development mode -A complete build generates Host contracts before compiling the Host, Client, and Web, so it is the deterministic entry for creating or refreshing all artifacts: +The repository `dsh` script completes the Host, Client, and Web build before starting the source Host. Web development runs that command and the Client plugin watcher in separate terminals: ```sh -pnpm run build -``` - -Web development normally starts the source Host after one complete build and runs the Client plugin watcher in another terminal: - -```sh -pnpm run dsh -- web --dev +pnpm dsh web --dev pnpm run dev:web ``` diff --git a/docs/api-gateway.zh.md b/docs/api-gateway.zh.md index 692cf825f6..aa9b726c33 100644 --- a/docs/api-gateway.zh.md +++ b/docs/api-gateway.zh.md @@ -17,7 +17,7 @@ Service 通常继承 `GatewayService`,让 Cordis service key 与默认 Remote ```ts import type { Agent } from '@deepseek-ai/dsh-agent' import { GatewayService, Remote, RemoteScope } from '@deepseek-ai/dsh-type-meta' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export interface CreateGoalRequest { objective: string @@ -60,7 +60,7 @@ Client 使用普通对象上的具体函数,不使用 JavaScript Proxy。直 ```ts ignore-check import type { SessionId } from '@deepseek-ai/dsh-session/types' import type { AgentContext } from '@deepseek-ai/dsh-client-runtime/client' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-api-remotes/client' export const inject = ['remote', 'remote.goals'] @@ -138,16 +138,10 @@ SRC 只解决 Host 源码进程的分发问题。Client 不会从运行中的 Ho ## 开发模式 -完整构建会先生成 Host 约定,再编译 Host、Client 与 Web,因此是建立或刷新所有产物的确定性入口: +仓库的 `dsh` 脚本会先完成 Host、Client 与 Web 构建,再启动源码 Host。Web 开发需要在两个终端中分别运行该命令和 Client plugin watcher: ```sh -pnpm run build -``` - -Web 开发通常在完成一次构建后启动源码 Host,并在另一个终端运行 Client plugin watcher: - -```sh -pnpm run dsh -- web --dev +pnpm dsh web --dev pnpm run dev:web ``` diff --git a/docs/architecture.i18n.yaml b/docs/architecture.i18n.yaml index 344bac8145..ea8b934c79 100644 --- a/docs/architecture.i18n.yaml +++ b/docs/architecture.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/architecture.md -architecture.md: 8d1c5a1be391e2455aefc69db89d96027aaf3efa -architecture.zh.md: 53bf9f54a5503ae40aa62a348822f9d92162dda2 +architecture.md: aeda7f9674e75a1e97549f25c13d571b3b37ee8c +architecture.zh.md: a25f20ba9babefeaab4636027e97d6f2d4ae8caf diff --git a/docs/architecture.md b/docs/architecture.md index 8d1c5a1be3..aeda7f9674 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -42,6 +42,7 @@ Harnesses are [Cordis](cordis-primer.md) contexts; packages contribute services, | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | background task registry, generic `task_*` controls | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | script-driven multi-agent orchestration | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | persisted same-session goals | +| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | lifecycle-bound editable feedback for individual assistant messages and its Host Remote contract | | `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | durable session-log storage | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | live-preferred exact/filter/trace queries over SQLite FTS, workspace-authorized model tools | | `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | log-backed fallbacks, one optional asynchronous provider | diff --git a/docs/architecture.zh.md b/docs/architecture.zh.md index 53bf9f54a5..a25f20ba9b 100644 --- a/docs/architecture.zh.md +++ b/docs/architecture.zh.md @@ -42,6 +42,7 @@ | `ctx.tasks` | [`tasks/`](../packages/tasks/README.md) | 后台任务注册表和通用 `task_*` 控制 | | `ctx.workflows` | [`workflow/`](../packages/workflow/README.md) | 脚本驱动的多 agent 编排 | | `ctx.goals` | [`goal/`](../packages/goal/README.md) | 持久化的同会话目标 | +| `ctx.messageFeedback` | [`feedback/`](../packages/feedback/README.md) | 绑定生命周期的单条 assistant 消息可编辑反馈及其 Host Remote 契约 | | `ctx.sessionPersistence` | [`session/`](../packages/session/README.md) | 会话日志的持久化存储 | | `ctx.sessionQuery` | [`session-query/`](../packages/session-query/README.md) | 基于 SQLite 全文搜索的实时优先精确检索/过滤/追踪、经工作区授权的模型工具 | | `ctx.sessionTitle` | [`session/session-title`](../packages/session/README.md) | 基于日志的回退标题和单个可选异步提供方 | diff --git a/docs/capability-seams.i18n.yaml b/docs/capability-seams.i18n.yaml index d6c4832447..c0091e400e 100644 --- a/docs/capability-seams.i18n.yaml +++ b/docs/capability-seams.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/capability-seams.md -capability-seams.md: c102167aa76b9ba613b1b434cb0aa58765d26106 -capability-seams.zh.md: 7c4eab8d5a2d890bdf4513bb9acdc81f55414642 +capability-seams.md: 64d20b1bfb609aec3acb3673e4589516bb315bfa +capability-seams.zh.md: 10b5116d12991319df55c551d51840bb566ac898 diff --git a/docs/capability-seams.md b/docs/capability-seams.md index c102167aa7..64d20b1bfb 100644 --- a/docs/capability-seams.md +++ b/docs/capability-seams.md @@ -30,6 +30,7 @@ flowchart LR pkg_session_query_sqlite["session-query-sqlite"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] + pkg_message_feedback["message-feedback"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -60,6 +61,7 @@ flowchart LR pkg_storage_domain["storage-domain"] svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] + svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"] svc_workspace["ctx.workspace
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] @@ -218,6 +220,7 @@ flowchart LR pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm pkg_llm_replay --> svc_llm + pkg_message_feedback --> svc_messageFeedback pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode @@ -322,6 +325,7 @@ flowchart LR svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude svc_sessionPersistence --> pkg_hooks_codex + svc_sessionPersistence --> pkg_message_feedback svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash @@ -334,6 +338,7 @@ flowchart LR svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants + svc_sessions --> pkg_message_feedback svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite @@ -344,6 +349,7 @@ flowchart LR svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain + svc_storageDomain --> pkg_message_feedback svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent @@ -392,16 +398,17 @@ flowchart LR | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), [`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop), [`compact-basic`](../packages/compact/compact-basic) | - | Adapters register provider implementations; the loop and compaction call the provider-neutral stream service. | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Owns isolated per-session replay folds; pressure consumers share immutable revisioned measurements. | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | Rewrites oversized current tool results through replayable single-node surface replacements before summary compaction. | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants) | - | Owns append-only Session instances and emits the durable session event feed. | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop), [`agent`](../packages/core/agent), [`session-persistence`](../packages/session/session-persistence), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`subagent-inprocess`](../packages/subagent/subagent-inprocess), [`invariants`](../packages/support/invariants), [`message-feedback`](../packages/feedback/message-feedback) | - | Owns append-only Session instances and emits the durable session event feed. | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session), [`agent`](../packages/core/agent), [`scope`](../packages/core/scope), [`agent-loop`](../packages/core/agent-loop) | - | Companion subpaths register owner-local checks; the service owns selection, uniqueness, child fibers, and package-attributed failures. | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader), [`api-gateway`](../packages/api/gateway) | - | Plugins register live zod contributions directly or through dsh-typert-loader; the API gateway consumes invocation descriptors and providers, while other runtime consumers query schemas and reflection metadata at their own edges. | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | Associates generated Remote descriptors with live Cordis services, resolves registered identities, and exposes unary calls through the shared Connection RPC carrier. | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl), [`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop), [`tool-bash`](../packages/bash/tool-bash), [`hooks-claude`](../packages/hooks/hooks-claude), [`hooks-codex`](../packages/hooks/hooks-codex), [`session-query`](../packages/session-query/session-query), [`session-query-sqlite`](../packages/session-query/session-query-sqlite), [`message-feedback`](../packages/feedback/message-feedback) | - | Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time. | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Plugins register namespace schemas and resolve layered values; providers store the raw document. The LLM adapters register their entry config as the composition base under the user section; the web gateway serves redacted layered descriptors and writes the user layer. | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek), [`llm-pi-ai`](../packages/llm/llm-pi-ai), `apiproxy` | - | Configuration carries references to secrets; providers own the values. Consumers resolve per operation, so a rotated credential reaches the very next request; the web gateway exposes value-free views and write-only storage. | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | The seam captures, redacts, and hands session records to one backend; nothing else consumes the service — its output leaves the process. | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json), [`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | Backends register side by side under names; data forms (domain first) mount on the hub and translate typed operations into opaque KV-unit primitives. | -| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | +| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace), [`message-feedback`](../packages/feedback/message-feedback) | - | Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state. | +| `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry. | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | Owns WorkspaceId-branded records over the domain facility; stable sessionIds accounts drive Host RPC and GUI projections. | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference), [`tool-session-query`](../packages/session-query/tool-session-query) | - | The interface supplies exact reads, filters, and traces; its concrete backend adds full-text reconciliation, ranking, snippets, and cursor generations, while the model consumer owns workspace authority and cursor-free rendering. | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | Projects bounded current-surface conversation snapshots into durable untrusted message context; host adapters own mention syntax. | diff --git a/docs/capability-seams.zh.md b/docs/capability-seams.zh.md index 7c4eab8d5a..10b5116d12 100644 --- a/docs/capability-seams.zh.md +++ b/docs/capability-seams.zh.md @@ -32,6 +32,7 @@ flowchart LR pkg_session_query_sqlite["session-query-sqlite"] pkg_subagent_inprocess["subagent-inprocess"] pkg_invariants["invariants"] + pkg_message_feedback["message-feedback"] svc_invariants["ctx.invariants
Package-owned invariant registry"] pkg_scope["scope"] pkg_typert_registry["typert-registry"] @@ -62,6 +63,7 @@ flowchart LR pkg_storage_domain["storage-domain"] svc_storageDomain["ctx.storageDomain
Domain data facility"] pkg_workspace["workspace"] + svc_messageFeedback["ctx.messageFeedback
Lifecycle-bound message feedback"] svc_workspace["ctx.workspace
Workspace entity registry"] svc_sessionQuery["ctx.sessionQuery
Session reads, traces, filters, and search"] pkg_session_reference["session-reference"] @@ -220,6 +222,7 @@ flowchart LR pkg_llm_deepseek --> svc_llm pkg_llm_pi_ai --> svc_llm pkg_llm_replay --> svc_llm + pkg_message_feedback --> svc_messageFeedback pkg_modules --> svc_clientModuleHost pkg_permission --> svc_permission pkg_plan_mode --> svc_planMode @@ -324,6 +327,7 @@ flowchart LR svc_sessionPersistence --> pkg_agent_loop svc_sessionPersistence --> pkg_hooks_claude svc_sessionPersistence --> pkg_hooks_codex + svc_sessionPersistence --> pkg_message_feedback svc_sessionPersistence --> pkg_session_query svc_sessionPersistence --> pkg_session_query_sqlite svc_sessionPersistence --> pkg_tool_bash @@ -336,6 +340,7 @@ flowchart LR svc_sessions --> pkg_agent svc_sessions --> pkg_agent_loop svc_sessions --> pkg_invariants + svc_sessions --> pkg_message_feedback svc_sessions --> pkg_session_persistence svc_sessions --> pkg_session_query svc_sessions --> pkg_session_query_sqlite @@ -346,6 +351,7 @@ flowchart LR svc_skills --> pkg_tool_skill svc_spillStore --> pkg_spill_policy svc_storage --> pkg_storage_domain + svc_storageDomain --> pkg_message_feedback svc_storageDomain --> pkg_workspace svc_subagents --> pkg_tool_ralph svc_subagents --> pkg_tool_subagent @@ -394,16 +400,17 @@ flowchart LR | `ctx.llm` | `seam` | [`llm`](../packages/llm/llm) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、[`llm-replay`](../packages/support/llm-replay) | [`agent-loop`](../packages/core/agent-loop)、[`compact-basic`](../packages/compact/compact-basic) | - | 适配器注册提供方实现;agent loop(智能体循环)与压缩功能调用提供方无关的流服务。 | | `ctx.tokenMeter` | `core` | [`token-meter`](../packages/llm/token-meter) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 拥有按会话隔离的回放折叠区;压力消费方共享不可变且带修订版本的测量结果。 | | `ctx.toolResultPrune` | `core` | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | - | [`compact-basic`](../packages/compact/compact-basic) | - | 在摘要压缩前,通过可回放的单节点表层替换来改写过大的当前工具结果。 | -| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop)、[`agent`](../packages/core/agent)、[`session-persistence`](../packages/session/session-persistence)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess)、[`invariants`](../packages/support/invariants) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | +| `ctx.sessions` | `core` | [`session`](../packages/core/session) | - | [`agent-loop`](../packages/core/agent-loop)、[`agent`](../packages/core/agent)、[`session-persistence`](../packages/session/session-persistence)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`subagent-inprocess`](../packages/subagent/subagent-inprocess)、[`invariants`](../packages/support/invariants)、[`message-feedback`](../packages/feedback/message-feedback) | - | 拥有仅追加的 Session 实例,并发出持久的会话事件流。 | | `ctx.invariants` | `core` | [`invariants`](../packages/support/invariants) | - | [`session`](../packages/core/session)、[`agent`](../packages/core/agent)、[`scope`](../packages/core/scope)、[`agent-loop`](../packages/core/agent-loop) | - | 配套子路径注册所属包本地的检查;该服务负责选择、唯一性、子 fiber,以及标明所属包的失败。 | | `ctx.typert` | `core` | [`typert-registry`](../packages/typert/registry) | - | [`typert-loader`](../packages/typert/loader)、[`api-gateway`](../packages/api/gateway) | - | 插件直接或通过 dsh-typert-loader 注册实时 zod 贡献;API 网关消费调用描述符和提供方,其他运行时消费方则在各自边界查询 schema 与反射元数据。 | | `ctx.typertGateway` | `core` | [`api-gateway`](../packages/api/gateway) | - | - | - | 将生成的 Remote 描述符与实时 Cordis 服务关联,解析已注册的身份,并通过共享的 Connection RPC 载体提供一元调用。 | -| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl)、[`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop)、[`tool-bash`](../packages/bash/tool-bash)、[`hooks-claude`](../packages/hooks/hooks-claude)、[`hooks-codex`](../packages/hooks/hooks-codex)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | +| `ctx.sessionPersistence` | `seam` | [`session-persistence`](../packages/session/session-persistence) | [`session-persistence-jsonl`](../packages/session/session-persistence-jsonl)、[`session-persistence-sqlite`](../packages/session/session-persistence-sqlite) | [`agent-loop`](../packages/core/agent-loop)、[`tool-bash`](../packages/bash/tool-bash)、[`hooks-claude`](../packages/hooks/hooks-claude)、[`hooks-codex`](../packages/hooks/hooks-codex)、[`session-query`](../packages/session-query/session-query)、[`session-query-sqlite`](../packages/session-query/session-query-sqlite)、[`message-feedback`](../packages/feedback/message-feedback) | - | 各后端持久化同一套 SessionEvent 词汇;应用在组合时选择后端。 | | `ctx.settings` | `seam` | [`settings`](../packages/settings/settings) | [`settings-local`](../packages/settings/settings-local) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、`apiproxy` | - | 插件注册命名空间 schema 并解析分层值;提供方存储原始文档。LLM(大语言模型)适配器在用户分区下将其入口配置注册为组合基础;Web 网关提供经过脱敏的分层描述符,并写入用户层。 | | `ctx.credentials` | `seam` | [`credentials`](../packages/credentials/credentials) | [`credentials-local`](../packages/credentials/credentials-local) | [`llm-deepseek`](../packages/llm/llm-deepseek)、[`llm-pi-ai`](../packages/llm/llm-pi-ai)、`apiproxy` | - | 配置携带对机密信息的引用;提供方拥有实际值。消费方按操作解析,因此轮换后的凭据会在紧接着的下一次请求中生效;Web 网关提供不含实际值的视图和只写存储。 | | `ctx.telemetry` | `seam` | [`session-telemetry`](../packages/session/session-telemetry) | [`session-telemetry-otel`](../packages/session/session-telemetry-otel) | - | - | 该 seam 捕获会话记录、进行脱敏并交给一个后端;没有其他组件消费该服务,其输出会离开当前进程。 | | `ctx.storage` | `seam` | [`storage`](../packages/storage/storage) | [`storage-json`](../packages/storage/storage-json)、[`storage-sqlite`](../packages/storage/storage-sqlite) | [`storage-domain`](../packages/storage/storage-domain) | - | 各后端以不同名称并列注册;数据形态(领域优先)挂载到枢纽上,并将类型化操作转换为不透明的 KV 单元原语。 | -| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 | +| `ctx.storageDomain` | `core` | [`storage-domain`](../packages/storage/storage-domain) | - | [`workspace`](../packages/workspace/workspace)、[`message-feedback`](../packages/feedback/message-feedback) | - | 等待所有已配置后端就绪,然后将领域形态发布为一个受生命周期约束的服务,用于类型化持久状态。 | +| `ctx.messageFeedback` | `core` | [`message-feedback`](../packages/feedback/message-feedback) | - | - | - | 拥有本地逐 assistant 消息反馈、生命周期与目标校验、逐条目 compare-and-set 及 Host 一元 Remote 契约,且不进入 Session 历史或遥测。 | | `ctx.workspace` | `core` | [`workspace`](../packages/workspace/workspace) | - | `apiproxy` | - | 通过领域设施拥有带 WorkspaceId 品牌类型的记录;稳定的 sessionIds 账户驱动 Host RPC 与 GUI 投影。 | | `ctx.sessionQuery` | `seam` | [`session-query`](../packages/session-query/session-query) | [`session-query-sqlite`](../packages/session-query/session-query-sqlite) | [`session-reference`](../packages/context/session-reference)、[`tool-session-query`](../packages/session-query/tool-session-query) | - | 该接口提供精确读取、过滤和追踪;具体后端还提供全文协调、排序、摘要片段和游标世代,而模型消费方负责工作区权限与不含游标的渲染。 | | `ctx.sessionReferences` | `core` | [`session-reference`](../packages/context/session-reference) | - | - | - | 将当前表层中有界的对话快照投影为持久但不可信的消息上下文;Host 适配器负责提及语法。 | diff --git a/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 889b2e24e7..75e5d33e09 100644 --- a/docs/config-catalog.i18n.yaml +++ b/docs/config-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/config-catalog.md -config-catalog.md: 471680f92dc44f3dd4e98ba9e946525ec79f25b0 -config-catalog.zh.md: bf78766799b02a1f6f21f935723abace108bc306 +config-catalog.md: 1e2416d479daaef0f9b6b03846406fab4c30f21a +config-catalog.zh.md: 5a9ecc5b30c55f70b2685fe9633430e68f30f6c8 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 471680f92d..1e2416d479 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -259,7 +259,7 @@ export interface Config { Depends on: [`ToolPresentationMode`](subsystems/tools.md) -Source: [`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) +Source: [`packages/core/agent-tool-mode/src/index.ts:38`](../packages/core/agent-tool-mode/src/index.ts) ## `@deepseek-ai/dsh-attachment-local` @@ -533,7 +533,7 @@ export interface Config { } ``` -Source: [`packages/fs/fs-local/src/index.ts:40`](../packages/fs/fs-local/src/index.ts) +Source: [`packages/fs/fs-local/src/index.ts:41`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-fs-sandbox` @@ -572,7 +572,7 @@ Source: [`packages/goal/goal/src/index.ts:116`](../packages/goal/goal/src/index. Requires: `agentDefaultModel` · `agents` · `sessions` ```ts config-catalog -/** Plugin config: the task, patched in by the launcher. */ +/** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -986,6 +986,8 @@ export interface ReplayModelConfig { description?: string /** Optional positive integer context capacity published by the replay adapter. */ contextWindow?: number + /** Optional declared input modalities, so a scenario can exercise capability gates (e.g. image-capable `read_image`). */ + inputModalities?: readonly ModelModality[] /** * Optional per-request output cap the replay route materializes when callers * omit one, so replay reconstructs the request header a live catalog produced. @@ -1001,9 +1003,9 @@ export interface ReplayModelConfig { } ``` -Depends on: [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +Depends on: [`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -Source: [`packages/support/llm-replay/src/index.ts:769`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:776`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -1086,6 +1088,8 @@ export interface StdioConfig { toolCallTimeoutMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean + /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ + reconnect?: ReconnectConfig } /** Config for connecting to an MCP server over Streamable HTTP (SSE). */ @@ -1106,10 +1110,38 @@ export interface StreamableHttpConfig { toolCallTimeoutMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean + /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ + reconnect?: ReconnectConfig +} + +/** Automatic reconnect policy for one MCP server connection. */ +export interface ReconnectConfig { + /** Reconnect automatically after a lost connection (default true). */ + enabled?: boolean + /** First reconnect delay in milliseconds; doubles per consecutive failed attempt (default 500). */ + initialDelayMs?: number + /** Backoff ceiling in milliseconds; also the uptime after which the attempt budget resets (default 30000). */ + maxDelayMs?: number + /** Consecutive failed attempts per outage before giving up for good (default 10). */ + maxAttempts?: number } ``` -Source: [`packages/mcp/mcp-client/src/index.ts:100`](../packages/mcp/mcp-client/src/index.ts) +Source: [`packages/mcp/mcp-client/src/index.ts:98`](../packages/mcp/mcp-client/src/index.ts) + +## `@deepseek-ai/dsh-message-feedback` + +Requires: `storageDomain` · `sessionPersistence` · `sessions` + +```ts config-catalog +/** Required deployment policy for optional notes. */ +export interface Config { + /** Maximum UTF-8 byte length accepted for one note. */ + readonly maxNoteBytes: number +} +``` + +Source: [`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts) ## `@deepseek-ai/dsh-permission` @@ -1161,6 +1193,8 @@ export interface Config { * variables. Empty text drops the section at render, matching the registry. */ text: string + /** Make this persona the complete system prompt, suppressing every other section. */ + complete?: boolean } ``` @@ -1306,22 +1340,6 @@ 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/self-modification/repository-plugin/src/index.ts:44`](../packages/self-modification/repository-plugin/src/index.ts) - ## `@deepseek-ai/dsh-sandbox-local` ```ts config-catalog @@ -1350,7 +1368,7 @@ export interface Config { } ``` -Source: [`packages/sandbox/sandbox-local/src/index.ts:43`](../packages/sandbox/sandbox-local/src/index.ts) +Source: [`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` @@ -1412,7 +1430,7 @@ export interface Config { export type JsonlCompression = 'zstd' | 'none' ``` -Source: [`packages/session/session-persistence-jsonl/src/index.ts:59`](../packages/session/session-persistence-jsonl/src/index.ts) +Source: [`packages/session/session-persistence-jsonl/src/index.ts:60`](../packages/session/session-persistence-jsonl/src/index.ts) ## `@deepseek-ai/dsh-session-persistence-sqlite` @@ -1455,7 +1473,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -Source: [`packages/session/session-persistence-sqlite/src/index.ts:67`](../packages/session/session-persistence-sqlite/src/index.ts) +Source: [`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-projection-cache` @@ -1576,7 +1594,7 @@ export enum TelemetryMode { Depends on: `BatchLogRecordProcessorOptions` (`@opentelemetry/sdk-logs`) · `OTLPExporterNodeConfigBase` (`@opentelemetry/otlp-exporter-base`) -Source: [`packages/session/session-telemetry-otel/src/index.ts:79`](../packages/session/session-telemetry-otel/src/index.ts) +Source: [`packages/session/session-telemetry-otel/src/index.ts:91`](../packages/session/session-telemetry-otel/src/index.ts) ## `@deepseek-ai/dsh-session-title` @@ -2003,7 +2021,7 @@ export interface Config { } ``` -Source: [`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` @@ -2114,7 +2132,7 @@ export interface Config { } ``` -Source: [`packages/fs/tool-fs/src/index.ts:24`](../packages/fs/tool-fs/src/index.ts) +Source: [`packages/fs/tool-fs/src/index.ts:25`](../packages/fs/tool-fs/src/index.ts) ## `@deepseek-ai/dsh-tool-fs-search` @@ -2534,33 +2552,28 @@ Source: [`packages/web/web/src/index.ts:55`](../packages/web/web/src/index.ts) Requires: `httpServer` ```ts config-catalog -/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */ +/** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode - /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * layer turns it off: its user is not interacting through the GUI, so the + * non-interactive layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean - /** - * LAN IPv4 addresses sampled once by the launcher when the effective bind - * is all-interfaces — the exact snapshot the /api trust fence was - * configured with, so the printed LAN URL can never name an address the - * fence rejects. Empty on a loopback bind. - */ - lanAddresses: string[] + /** Explicit `--trusted-host` authorities from this invocation. */ + trustedHosts: string[] } /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' ``` -Source: [`packages/bundle/web-app/src/index.ts:32`](../packages/bundle/web-app/src/index.ts) +Source: [`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` @@ -2739,6 +2752,7 @@ These load from a `cordis.yml` entry with no `config:` block; they declare no co - `@deepseek-ai/dsh-client-ui-skill` ([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slash` ([`packages/client/ui-slash/src/index.ts`](../packages/client/ui-slash/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent` ([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-task` ([`packages/client/ui-task/src/index.ts`](../packages/client/ui-task/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme` ([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-tool` ([`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory` ([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) @@ -2806,6 +2820,7 @@ Imported as libraries by other packages; a `cordis.yml` cannot load them. - `@deepseek-ai/dsh-client-ui-slots` ([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web` ([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react` ([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-cmdline` ([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-environment` ([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper` ([`packages/scaffold/helper/src/index.ts`](../packages/scaffold/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol` ([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index bf78766799..5a9ecc5b30 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -261,7 +261,7 @@ export interface Config { 依赖:[`ToolPresentationMode`](subsystems/tools.md) -来源:[`packages/core/agent-tool-mode/src/index.ts:36`](../packages/core/agent-tool-mode/src/index.ts) +来源:[`packages/core/agent-tool-mode/src/index.ts:38`](../packages/core/agent-tool-mode/src/index.ts) ## `@deepseek-ai/dsh-attachment-local` @@ -574,7 +574,7 @@ export interface Config { 需要:`agentDefaultModel` · `agents` · `sessions` ```ts config-catalog -/** Plugin config: the task, patched in by the launcher. */ +/** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -988,6 +988,8 @@ export interface ReplayModelConfig { description?: string /** Optional positive integer context capacity published by the replay adapter. */ contextWindow?: number + /** Optional declared input modalities, so a scenario can exercise capability gates (e.g. image-capable `read_image`). */ + inputModalities?: readonly ModelModality[] /** * Optional per-request output cap the replay route materializes when callers * omit one, so replay reconstructs the request header a live catalog produced. @@ -1003,9 +1005,9 @@ export interface ReplayModelConfig { } ``` -依赖:[`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) +依赖:[`ModelModality`](../packages/llm/llm/src/index.ts) · [`RetryPolicyConfig`](../packages/llm/llm/src/index.ts) -来源:[`packages/support/llm-replay/src/index.ts:769`](../packages/support/llm-replay/src/index.ts) +来源:[`packages/support/llm-replay/src/index.ts:776`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -1088,6 +1090,8 @@ export interface StdioConfig { toolCallTimeoutMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean + /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ + reconnect?: ReconnectConfig } /** Config for connecting to an MCP server over Streamable HTTP (SSE). */ @@ -1108,10 +1112,38 @@ export interface StreamableHttpConfig { toolCallTimeoutMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean + /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ + reconnect?: ReconnectConfig +} + +/** Automatic reconnect policy for one MCP server connection. */ +export interface ReconnectConfig { + /** Reconnect automatically after a lost connection (default true). */ + enabled?: boolean + /** First reconnect delay in milliseconds; doubles per consecutive failed attempt (default 500). */ + initialDelayMs?: number + /** Backoff ceiling in milliseconds; also the uptime after which the attempt budget resets (default 30000). */ + maxDelayMs?: number + /** Consecutive failed attempts per outage before giving up for good (default 10). */ + maxAttempts?: number } ``` -来源:[`packages/mcp/mcp-client/src/index.ts:100`](../packages/mcp/mcp-client/src/index.ts) +来源:[`packages/mcp/mcp-client/src/index.ts:94`](../packages/mcp/mcp-client/src/index.ts) + +## `@deepseek-ai/dsh-message-feedback` + +需要:`storageDomain` · `sessionPersistence` · `sessions` + +```ts config-catalog +/** Required deployment policy for optional notes. */ +export interface Config { + /** Maximum UTF-8 byte length accepted for one note. */ + readonly maxNoteBytes: number +} +``` + +来源:[`packages/feedback/message-feedback/src/index.ts:49`](../packages/feedback/message-feedback/src/index.ts) ## `@deepseek-ai/dsh-permission` @@ -1163,6 +1195,8 @@ export interface Config { * variables. Empty text drops the section at render, matching the registry. */ text: string + /** Make this persona the complete system prompt, suppressing every other section. */ + complete?: boolean } ``` @@ -1308,22 +1342,6 @@ export interface Config { 来源:[`packages/guard/repeat-tool-guard/src/index.ts:28`](../packages/guard/repeat-tool-guard/src/index.ts) -## `@deepseek-ai/dsh-repository-plugin` - -需要:`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 -} -``` - -来源:[`packages/self-modification/repository-plugin/src/index.ts:44`](../packages/self-modification/repository-plugin/src/index.ts) - ## `@deepseek-ai/dsh-sandbox-local` ```ts config-catalog @@ -1352,7 +1370,7 @@ export interface Config { } ``` -来源:[`packages/sandbox/sandbox-local/src/index.ts:43`](../packages/sandbox/sandbox-local/src/index.ts) +来源:[`packages/sandbox/sandbox-local/src/index.ts:44`](../packages/sandbox/sandbox-local/src/index.ts) ## `@deepseek-ai/dsh-sandbox-policy` @@ -1457,7 +1475,7 @@ export interface Config { export type JournalMode = 'wal' | 'delete' | 'truncate' | 'persist' ``` -来源:[`packages/session/session-persistence-sqlite/src/index.ts:67`](../packages/session/session-persistence-sqlite/src/index.ts) +来源:[`packages/session/session-persistence-sqlite/src/index.ts:70`](../packages/session/session-persistence-sqlite/src/index.ts) ## `@deepseek-ai/dsh-session-projection-cache` @@ -2005,7 +2023,7 @@ export interface Config { } ``` -来源:[`packages/core/system-prompt/src/index.ts:177`](../packages/core/system-prompt/src/index.ts) +来源:[`packages/core/system-prompt/src/index.ts:186`](../packages/core/system-prompt/src/index.ts) ## `@deepseek-ai/dsh-time-context` @@ -2535,33 +2553,28 @@ export interface WebServiceConfig { 需要:`httpServer` ```ts config-catalog -/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */ +/** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode - /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * layer turns it off: its user is not interacting through the GUI, so the + * non-interactive layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean - /** - * LAN IPv4 addresses sampled once by the launcher when the effective bind - * is all-interfaces — the exact snapshot the /api trust fence was - * configured with, so the printed LAN URL can never name an address the - * fence rejects. Empty on a loopback bind. - */ - lanAddresses: string[] + /** Explicit `--trusted-host` authorities from this invocation. */ + trustedHosts: string[] } /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' ``` -来源:[`packages/bundle/web-app/src/index.ts:32`](../packages/bundle/web-app/src/index.ts) +来源:[`packages/bundle/web-app/src/index.ts:43`](../packages/bundle/web-app/src/index.ts) ## `@deepseek-ai/dsh-web-fetch-local` @@ -2740,6 +2753,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-skill`([`packages/client/ui-skill/src/index.ts`](../packages/client/ui-skill/src/index.ts)) - `@deepseek-ai/dsh-client-ui-slash`([`packages/client/ui-slash/src/index.ts`](../packages/client/ui-slash/src/index.ts)) - `@deepseek-ai/dsh-client-ui-subagent`([`packages/client/ui-subagent/src/index.ts`](../packages/client/ui-subagent/src/index.ts)) +- `@deepseek-ai/dsh-client-ui-task`([`packages/client/ui-task/src/index.ts`](../packages/client/ui-task/src/index.ts)) - `@deepseek-ai/dsh-client-ui-theme`([`packages/client/ui-theme/src/index.ts`](../packages/client/ui-theme/src/index.ts)) - `@deepseek-ai/dsh-client-ui-tool`([`packages/client/ui-tool/src/index.ts`](../packages/client/ui-tool/src/index.ts)) - `@deepseek-ai/dsh-client-ui-trajectory`([`packages/client/ui-trajectory/src/index.ts`](../packages/client/ui-trajectory/src/index.ts)) @@ -2806,6 +2820,7 @@ export interface Config { - `@deepseek-ai/dsh-client-ui-slots`([`packages/client/ui-slots/src/index.ts`](../packages/client/ui-slots/src/index.ts)) - `@deepseek-ai/dsh-client-web`([`packages/client/web/src/index.ts`](../packages/client/web/src/index.ts)) - `@deepseek-ai/dsh-client-web-react`([`packages/client/web-react/src/index.ts`](../packages/client/web-react/src/index.ts)) +- `@deepseek-ai/dsh-cmdline`([`packages/boot/cmdline/src/index.ts`](../packages/boot/cmdline/src/index.ts)) - `@deepseek-ai/dsh-environment`([`packages/util/environment/src/index.ts`](../packages/util/environment/src/index.ts)) - `@deepseek-ai/dsh-helper`([`packages/scaffold/helper/src/index.ts`](../packages/scaffold/helper/src/index.ts)) - `@deepseek-ai/dsh-hook-protocol`([`packages/hooks/hook-protocol/src/index.ts`](../packages/hooks/hook-protocol/src/index.ts)) diff --git a/docs/cookbook/adding-a-conversation-node.i18n.yaml b/docs/cookbook/adding-a-conversation-node.i18n.yaml index aa268e9461..52234b5562 100644 --- a/docs/cookbook/adding-a-conversation-node.i18n.yaml +++ b/docs/cookbook/adding-a-conversation-node.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-conversation-node.md -adding-a-conversation-node.md: ea4ec73eb109af6b0e4c7cf50fc8692942c75dd4 -adding-a-conversation-node.zh.md: 4b9a8049e2f1d060ec4bc3334036559b989ea562 +adding-a-conversation-node.md: c1965dc8a3081eebb8c1026ac53d2f7b8964edb7 +adding-a-conversation-node.zh.md: 92445e1432369a4e42cc372b5d5869c3cdeada4a diff --git a/docs/cookbook/adding-a-conversation-node.md b/docs/cookbook/adding-a-conversation-node.md index ea4ec73eb1..c1965dc8a3 100644 --- a/docs/cookbook/adding-a-conversation-node.md +++ b/docs/cookbook/adding-a-conversation-node.md @@ -120,6 +120,7 @@ function viewData(state: ReviewState): ReviewChatData { const reviewDefinition: ConversationNodeDefinition = { kind: 'review-job', + target: 'chat', match: (event) => { if (event.type === 'review/start') { return { id: String(event.data.reviewId), role: 'start' } @@ -161,8 +162,8 @@ const reviewDefinition: ConversationNodeDefinition = { value: viewData(context.state), } }, - buildViewNode: (context, target) => { - if (target !== 'chat' || context.state === undefined) return null + buildViewNode: (context) => { + if (context.state === undefined) return null return { key: context.key, kind: 'review-job', @@ -196,7 +197,7 @@ export function apply(ctx: ClientContext): void { `buildLocationData(context, scope)` optionally publishes Definition-owned data onto an engine-owned Turn or Step. Use declaration merging to give each key a precise value type. Another Node in the same Location can consume that value through its constrained slot hook, such as `useTurnData(key)`, without receiving the Session or scanning `snapshot.chat.nodes`. -`buildViewNode(context, target)` materializes the final target-specific Node. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. +`target` and `buildViewNode(context)` declare one target-owned rendering contribution and must appear together. Preserve `context.key` as the React-facing identity, choose `anchorSeq` from durable ordering evidence, and return only renderer-ready data. Once a target Node has been published, keep returning the same key; use `visibility: 'hidden'` when it must temporarily leave the visible flow rather than withdrawing it with `null`. ## 3. Query an earlier business Context only at start diff --git a/docs/cookbook/adding-a-conversation-node.zh.md b/docs/cookbook/adding-a-conversation-node.zh.md index 4b9a8049e2..92445e1432 100644 --- a/docs/cookbook/adding-a-conversation-node.zh.md +++ b/docs/cookbook/adding-a-conversation-node.zh.md @@ -120,6 +120,7 @@ function viewData(state: ReviewState): ReviewChatData { const reviewDefinition: ConversationNodeDefinition = { kind: 'review-job', + target: 'chat', match: (event) => { if (event.type === 'review/start') { return { id: String(event.data.reviewId), role: 'start' } @@ -161,8 +162,8 @@ const reviewDefinition: ConversationNodeDefinition = { value: viewData(context.state), } }, - buildViewNode: (context, target) => { - if (target !== 'chat' || context.state === undefined) return null + buildViewNode: (context) => { + if (context.state === undefined) return null return { key: context.key, kind: 'review-job', @@ -196,7 +197,7 @@ export function apply(ctx: ClientContext): void { `buildLocationData(context, scope)` 可以把 Definition 拥有的数据发布到引擎拥有的 Turn 或 Step 上。通过 declaration merging 为每个 key 指定精确 value 类型。同一 Location 内的另一个 Node 可以使用受限 slot hook(例如 `useTurnData(key)`)读取该值,无须取得 Session,也无须扫描 `snapshot.chat.nodes`。 -`buildViewNode(context, target)` 物化最终的目标专用 Node。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。 +`target` 与 `buildViewNode(context)` 必须同时声明一项由 target 拥有的渲染贡献。把 `context.key` 保留为 React 侧身份,根据持久排序证据选择 `anchorSeq`,并且只返回 renderer 可以直接使用的数据。某个 target Node 一旦发布,就要继续返回同一个 key;需要暂时离开可见流时使用 `visibility: 'hidden'`,不要改为返回 `null` 撤回它。 ## 3. 只在 start 时查询更早的业务 Context diff --git a/docs/cookbook/adding-a-package.i18n.yaml b/docs/cookbook/adding-a-package.i18n.yaml index 5bdff5df21..0dcfdafacd 100644 --- a/docs/cookbook/adding-a-package.i18n.yaml +++ b/docs/cookbook/adding-a-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-package.md -adding-a-package.md: dcd5fa66f3616c2c22930babd09cb3edab38e182 -adding-a-package.zh.md: c8769197e0b1db31348b7f2442dbcd636bf43cb2 +adding-a-package.md: e108b9e88e0f0e470f96173306af79c69ff695fb +adding-a-package.zh.md: 97b4df150fda7010fba048d7acb5e23a87519911 diff --git a/docs/cookbook/adding-a-package.md b/docs/cookbook/adding-a-package.md index dcd5fa66f3..e108b9e88e 100644 --- a/docs/cookbook/adding-a-package.md +++ b/docs/cookbook/adding-a-package.md @@ -22,7 +22,7 @@ packages/// Choose an existing group when one matches the package's role (`core`, `llm`, `bash`, `compact`, `subagent`, `todo`, `session-persistence`, `ui`, `util`, or `support`). A new group is allowed, but it is a pure container: no `package.json`, no source files, and packages still sit exactly one level below it. -package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, a `version` matching the root `package.json`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list contains exactly `lib/index.js`, `lib/invariant.js`, `lib/types/**/*.d.ts`, and package-specific runtime artifacts recognized by the gate; a package whose runtime export points into the emitted tree also includes `lib/types/**/*.js`. Do not publish `src`, declaration maps, JS maps, or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. +package.json invariants (enforced by `pnpm run constraints` / `scripts/check-workspace-constraints.ts`): `private: true`, a `version` matching the root `package.json`, `type: module`, `main: "lib/index.js"`, `types: "lib/types/index.d.ts"`, `exports["."].types: "./lib/types/index.d.ts"`, `exports["."].default: "./lib/index.js"`, `@deepseek-ai/cordis` in BOTH peerDependencies and devDependencies (same range). Mirror every dsh peer dependency in devDependencies. `@deepseek-ai/schemastery` goes in `dependencies` (it is a runtime validator), matching agent-loop. The `files` list contains exactly `lib/index.js`, `lib/invariant.js`, `lib/types/**/*.d.ts`, and package-specific runtime artifacts recognized by the gate; a package whose runtime export points into the emitted tree also includes `lib/types/**/*.js`. Do not publish `src`, declaration maps, JS maps, or stale root declaration files. CLI app packages with a package `bin` include `lib/bin.js` immediately after `lib/index.js` in `files`. In-package relative imports use explicit `.ts` specifiers in source (for example, `export * from './types.ts'`). The compiler rewrites those to `.js` in emitted JS and leaves explicit `.ts` specifiers in declarations, which standard NodeNext/Node16 TypeScript consumers resolve to the sibling `.d.ts` files. diff --git a/docs/cookbook/adding-a-package.zh.md b/docs/cookbook/adding-a-package.zh.md index c8769197e0..97b4df150f 100644 --- a/docs/cookbook/adding-a-package.zh.md +++ b/docs/cookbook/adding-a-package.zh.md @@ -22,7 +22,7 @@ packages/// 当已有分组与包的角色匹配时,选择该分组(`core`、`llm`、`bash`、`compact`、`subagent`、`todo`、`session-persistence`、`ui`、`util` 或 `support`)。允许新建分组,但分组只是纯容器:没有 `package.json`,没有源文件,包仍然恰好位于其下一层。 -package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表精确包含 `lib/index.js`、`lib/invariant.js`、`lib/types/**/*.d.ts` 以及门禁认可的包专用运行时产物;如果包的运行时 export 指向输出树,还要包含 `lib/types/**/*.js`。不要发布 `src`、声明映射、JS map 或陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。 +package.json 不变式(由 `pnpm run constraints` / `scripts/check-workspace-constraints.ts` 强制执行):`private: true`,`version` 与根 `package.json` 一致,`type: module`,`main: "lib/index.js"`,`types: "lib/types/index.d.ts"`,`exports["."].types: "./lib/types/index.d.ts"`,`exports["."].default: "./lib/index.js"`,`@deepseek-ai/cordis` 同时出现在 peerDependencies 和 devDependencies 中(相同范围)。每个 dsh 对等依赖(peer dependency)都要在 devDependencies 中镜像。`@deepseek-ai/schemastery` 放在 `dependencies` 中(它是运行时校验器),与 agent-loop 保持一致。`files` 列表精确包含 `lib/index.js`、`lib/invariant.js`、`lib/types/**/*.d.ts` 以及门禁认可的包专用运行时产物;如果包的运行时 export 指向输出树,还要包含 `lib/types/**/*.js`。不要发布 `src`、声明映射、JS map 或陈旧的根声明文件。带有 `bin` 的 CLI 应用包在 `files` 中将 `lib/bin.js` 紧跟在 `lib/index.js` 之后。 包内的相对导入在源码中使用显式 `.ts` 后缀(例如 `export * from './types.ts'`)。编译器在输出的 JS 中将其重写为 `.js`,在声明文件中保留显式 `.ts` 后缀;标准的 NodeNext/Node16 TypeScript 消费方会将其解析到同目录的 `.d.ts` 文件。 diff --git a/docs/cookbook/adding-a-tool.i18n.yaml b/docs/cookbook/adding-a-tool.i18n.yaml index e232ad4302..5b47beab61 100644 --- a/docs/cookbook/adding-a-tool.i18n.yaml +++ b/docs/cookbook/adding-a-tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-tool.md -adding-a-tool.md: b030d3c3a6b7dd66b6594779345a96af3a895bd8 -adding-a-tool.zh.md: 4272a7a4571782bc213ca29de1a57d51fbd24075 +adding-a-tool.md: fa39c4b97f3c0eb739ea34d1b43ef46d11285bbf +adding-a-tool.zh.md: ab32e90fac5539ee403a36b2dd52e60db3ad603c diff --git a/docs/cookbook/adding-a-tool.md b/docs/cookbook/adding-a-tool.md index b030d3c3a6..fa39c4b97f 100644 --- a/docs/cookbook/adding-a-tool.md +++ b/docs/cookbook/adding-a-tool.md @@ -8,7 +8,7 @@ Reference for the contracts a model-facing tool must satisfy. For an ordered fir ```ts import { readFile } from 'node:fs/promises' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'my-tool' diff --git a/docs/cookbook/adding-a-tool.zh.md b/docs/cookbook/adding-a-tool.zh.md index 4272a7a457..ab32e90fac 100644 --- a/docs/cookbook/adding-a-tool.zh.md +++ b/docs/cookbook/adding-a-tool.zh.md @@ -8,7 +8,7 @@ ```ts import { readFile } from 'node:fs/promises' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'my-tool' diff --git a/docs/cookbook/adding-a-vendored-package.i18n.yaml b/docs/cookbook/adding-a-vendored-package.i18n.yaml index 4f5b8c3c49..b17f3390cf 100644 --- a/docs/cookbook/adding-a-vendored-package.i18n.yaml +++ b/docs/cookbook/adding-a-vendored-package.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/adding-a-vendored-package.md -adding-a-vendored-package.md: 724d89c1c7cd728f7123a6975b5500cd40815851 -adding-a-vendored-package.zh.md: d16ec1056431a4ac1c02d50a5ef0f0a64b67ca6d +adding-a-vendored-package.md: 239ac27565204332559038014fabae83fc2d1057 +adding-a-vendored-package.zh.md: 2bf5c7eeaffec594dbc4d13e0aa33b12a9eccf4f diff --git a/docs/cookbook/adding-a-vendored-package.md b/docs/cookbook/adding-a-vendored-package.md index 724d89c1c7..239ac27565 100644 --- a/docs/cookbook/adding-a-vendored-package.md +++ b/docs/cookbook/adding-a-vendored-package.md @@ -8,7 +8,7 @@ When the harness needs another upstream Cordis package (e.g. `@cordisjs/plugin-h ``` vendor// - package.json # from upstream; set "private": true, keep name/exports/type + package.json # from upstream; set "private": true, rescope the name, keep exports/type tsconfig.json # extends ../../tsconfig.base.json (see configuration below) src/ # the upstream src/ verbatim README.md LICENSE # if upstream ships them @@ -29,7 +29,7 @@ vendor// } ``` -`package.json` invariants: `"private": true` (vendored packages are never published), keep upstream's `name`/`version`/`exports`/`type`, point declaration metadata at `lib/types`, publish `.d.ts` and `.d.ts.map` declaration outputs, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). +`package.json` invariants: `"private": true` (vendored packages are never published), rescope the `name` ([mapping](../rescope.md)) while keeping upstream's `version`/`exports`/`type`, point declaration metadata at `lib/types`, publish `.d.ts` and `.d.ts.map` declaration outputs, and list its cordis deps in `peerDependencies` (matching the upstream manifest). Transitive upstream deps must themselves be vendored or already present — vendoring one package often means vendoring its dependency tree (e.g. `@cordisjs/plugin-http` pulls `@cordisjs/fetch-file`). Local relative imports/exports in vendored TypeScript source use explicit `.ts` specifiers after copying. This is a repo-local build difference from upstream: `rewriteRelativeImportExtensions` emits `.js` runtime imports while declarations keep explicit `.ts` specifiers that NodeNext/Node16 TypeScript consumers can resolve. diff --git a/docs/cookbook/adding-a-vendored-package.zh.md b/docs/cookbook/adding-a-vendored-package.zh.md index d16ec10564..2bf5c7eeaf 100644 --- a/docs/cookbook/adding-a-vendored-package.zh.md +++ b/docs/cookbook/adding-a-vendored-package.zh.md @@ -8,7 +8,7 @@ ``` vendor// - package.json # from upstream; set "private": true, keep name/exports/type + package.json # from upstream; set "private": true, rescope the name, keep exports/type tsconfig.json # extends ../../tsconfig.base.json (see configuration below) src/ # the upstream src/ verbatim README.md LICENSE # if upstream ships them @@ -29,7 +29,7 @@ vendor// } ``` -`package.json` 的不变式:`"private": true`(vendored 包永不发布);保留上游的 `name`/`version`/`exports`/`type`;声明元数据指向 `lib/types`;发布 `.d.ts` 与 `.d.ts.map` 声明输出;在 `peerDependencies` 中列出其 Cordis 依赖(与上游 manifest(元数据清单)一致)。传递性上游依赖本身也必须被 vendor 或已存在于仓库中——vendor 一个包往往意味着 vendor 其整条依赖树(如 `@cordisjs/plugin-http` 会拉入 `@cordisjs/fetch-file`)。 +`package.json` 的不变式:`"private": true`(vendored 包永不发布);改写 `name` 的 scope([映射](../rescope.md)),保留上游的 `version`/`exports`/`type`;声明元数据指向 `lib/types`;发布 `.d.ts` 与 `.d.ts.map` 声明输出;在 `peerDependencies` 中列出其 Cordis 依赖(与上游 manifest(元数据清单)一致)。传递性上游依赖本身也必须被 vendor 或已存在于仓库中——vendor 一个包往往意味着 vendor 其整条依赖树(如 `@cordisjs/plugin-http` 会拉入 `@cordisjs/fetch-file`)。 vendored TypeScript 源码中的本地相对导入/导出在复制后使用显式 `.ts` 后缀。这是仓库本地构建与上游的差异:`rewriteRelativeImportExtensions` 输出 `.js` 运行时导入,而声明文件保留显式 `.ts` 后缀,使 NodeNext/Node16 的 TypeScript 消费方能够解析。 diff --git a/docs/cookbook/extension-cookbook.i18n.yaml b/docs/cookbook/extension-cookbook.i18n.yaml index ac600e8c6d..3a98437564 100644 --- a/docs/cookbook/extension-cookbook.i18n.yaml +++ b/docs/cookbook/extension-cookbook.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cookbook/extension-cookbook.md -extension-cookbook.md: 95ba269a5d62e14cfde487d5a3aaca5db493657e -extension-cookbook.zh.md: e3fbe09f1ec09568e3b259aee361d33ba3e62140 +extension-cookbook.md: f292075dfdad5016d81521318b38594e3d7ee8b4 +extension-cookbook.zh.md: 0623c49d9d7075b3823c1fd340b36a5fba21f31e diff --git a/docs/cookbook/extension-cookbook.md b/docs/cookbook/extension-cookbook.md index 95ba269a5d..f292075dfd 100644 --- a/docs/cookbook/extension-cookbook.md +++ b/docs/cookbook/extension-cookbook.md @@ -13,7 +13,7 @@ A tool registers on `ctx.tools`. The annotated `defineTool` example (typed `exec This permission gate is one example of a hook plugin. It returns a typed decision from the `tools/pre-execute` gate to allow or deny a call; sandbox, permission, and plan-mode plugins can use this extension point. Hook plugins can intercept other extension points and are not inherently permission gates. A "native hook" is an ordinary Cordis plugin on an interception point; it needs no external protocol. ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' declare function isAllowed(exec: ToolExecution): Promise @@ -37,7 +37,7 @@ This waterfall is the reorderable policy layer. Use `ctx.tools.guard()` when an A UI plugin renders from the `session/event` feed (the assistant token stream as `assistant/chunk`, plus turn/step boundaries and tool activity), and drives input back in via `agent.followup()` / `agent.steer()`. A browser plugin contributing a business row to the built-in Web Client instead registers a `ConversationNodeDefinition` and keyed Chat renderer; follow the [Conversation Node guide](adding-a-conversation-node.md). ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -67,7 +67,7 @@ A *protocol driver* adapts a wire peer to `ctx.agents`; it may serve a UI or an [`packages/acp/acp`](../../packages/acp/acp) is the automation-only worked example: it exposes fresh text sessions over Agent Client Protocol JSON-RPC stdio, emits committed assistant text, and registers a one-shot machine permission answerer for agents it owns. Its [README](../../packages/acp/acp/README.md) defines the exact methods, event order, and lifecycle contract. ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'my-protocol-bridge' export const inject = ['agents', 'sessions', 'sessionPersistence'] diff --git a/docs/cookbook/extension-cookbook.zh.md b/docs/cookbook/extension-cookbook.zh.md index e3fbe09f1e..0623c49d9d 100644 --- a/docs/cookbook/extension-cookbook.zh.md +++ b/docs/cookbook/extension-cookbook.zh.md @@ -13,7 +13,7 @@ harness 扩展的参考模式。代码片段省略了 import 和辅助实现, 这个权限门禁是钩子插件的一个示例。它从 `tools/pre-execute` 门禁返回一个类型化的决策,用于允许或拒绝一次调用;沙箱、权限和 plan-mode 插件都可以使用该扩展点。钩子插件也可以拦截其他扩展点,本身并不等同于权限门禁。「原生钩子」是在拦截点上运行的普通 Cordis 插件,不需要外部协议。 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { PreToolDecision, ToolExecution } from '@deepseek-ai/dsh-tools' declare function isAllowed(exec: ToolExecution): Promise @@ -37,7 +37,7 @@ export function apply(ctx: Context) { UI 插件从 `session/event` 事件流渲染(助手 token 流以 `assistant/chunk` 形式到达,加上轮次/步骤边界与工具活动),并通过 `agent.followup()` / `agent.steer()` 将输入驱动回去。如果浏览器插件要向内建 Web Client 贡献业务行,则应注册 `ConversationNodeDefinition` 与 keyed Chat renderer;具体步骤见 [Conversation Node 指南](adding-a-conversation-node.md)。 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' @@ -67,7 +67,7 @@ export function apply(ctx: Context) { [`packages/acp/acp`](../../packages/acp/acp) 是仅面向自动化的完整示例:它通过 ACP(Agent Client Protocol)JSON-RPC stdio 提供全新文本会话,发出已提交的助手文本,并为其拥有的 agent 注册一次性机器权限应答器。其 [README](../../packages/acp/acp/README.md) 定义确切的方法、事件顺序和生命周期约定。 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'my-protocol-bridge' export const inject = ['agents', 'sessions', 'sessionPersistence'] diff --git a/docs/cordis-api/fiber.i18n.yaml b/docs/cordis-api/fiber.i18n.yaml index 6c01366dc1..537be01dbc 100644 --- a/docs/cordis-api/fiber.i18n.yaml +++ b/docs/cordis-api/fiber.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-api/fiber.md -fiber.md: 36d2861ac6a53e8186a92d86c65ba228d4b59ee5 -fiber.zh.md: fafa559ca911677c43893862190009d82c39c56b +fiber.md: 182b77390b29b8a90504437d0ccc2dfeba23921a +fiber.zh.md: 9ed3e52618586dc3815b9d913439d11a227fb64b diff --git a/docs/cordis-api/fiber.md b/docs/cordis-api/fiber.md index 36d2861ac6..182b77390b 100644 --- a/docs/cordis-api/fiber.md +++ b/docs/cordis-api/fiber.md @@ -34,7 +34,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../vendor/cordis/src/fiber.ts#L420) +[Source](../../vendor/cordis/src/fiber.ts#L415) ### ctx.fiber @@ -97,7 +97,7 @@ public state Current lifecycle state; transitions emit `internal/status`. -[Source](../../vendor/cordis/src/fiber.ts#L192) +[Source](../../vendor/cordis/src/fiber.ts#L194) ### fiber.dispose @@ -108,7 +108,7 @@ public readonly dispose: () => Promise Dispose this fiber: unload the plugin, then settle once cleanup finished. -[Source](../../vendor/cordis/src/fiber.ts#L194) +[Source](../../vendor/cordis/src/fiber.ts#L196) ### fiber.store @@ -119,7 +119,7 @@ public store: Dict | undefined Snapshot of required service implementations while loaded; `undefined` otherwise. -[Source](../../vendor/cordis/src/fiber.ts#L196) +[Source](../../vendor/cordis/src/fiber.ts#L198) ### fiber.inertia @@ -130,7 +130,7 @@ public inertia: Promise | undefined The in-flight load/unload transition, if one is currently running. -[Source](../../vendor/cordis/src/fiber.ts#L198) +[Source](../../vendor/cordis/src/fiber.ts#L200) ### fiber.name @@ -141,7 +141,7 @@ get name() The plugin's display name, inherited from the nearest named ancestor, else `'root'`. -[Source](../../vendor/cordis/src/fiber.ts#L341) +[Source](../../vendor/cordis/src/fiber.ts#L336) ### fiber.assertActive() @@ -159,7 +159,7 @@ Throw if the fiber has already been disposed. **Returns** nothing when the fiber is still active. -[Source](../../vendor/cordis/src/fiber.ts#L356) +[Source](../../vendor/cordis/src/fiber.ts#L351) ### fiber.effect(execute, label?) @@ -190,7 +190,7 @@ Register a cleanup-aware effect on this fiber. **Returns** a disposer that tears the effect down and settles once done. -[Source](../../vendor/cordis/src/fiber.ts#L420) +[Source](../../vendor/cordis/src/fiber.ts#L415) ### fiber.getEffects() @@ -207,7 +207,7 @@ Return metadata for currently registered effects. **Returns** one `EffectMeta` tree per labeled live effect. -[Source](../../vendor/cordis/src/fiber.ts#L573) +[Source](../../vendor/cordis/src/fiber.ts#L568) ### fiber.await() @@ -225,7 +225,7 @@ Wait for current lifecycle work and rethrow startup errors. **Returns** this fiber, once it has settled into a stable state. -[Source](../../vendor/cordis/src/fiber.ts#L702) +[Source](../../vendor/cordis/src/fiber.ts#L704) ### fiber.restart() @@ -243,7 +243,7 @@ Dispose and immediately reload this plugin with its current config. **Returns** a promise resolving once the reload settled. -[Source](../../vendor/cordis/src/fiber.ts#L716) +[Source](../../vendor/cordis/src/fiber.ts#L718) ### fiber.update(config, noSave?) @@ -271,7 +271,7 @@ Runs the `internal/update` waterfall first, so update hooks (and HMR) can veto o **Returns** the update waterfall result; the default restart returns a promise. -[Source](../../vendor/cordis/src/fiber.ts#L734) +[Source](../../vendor/cordis/src/fiber.ts#L736) ## Effect diff --git a/docs/cordis-api/fiber.zh.md b/docs/cordis-api/fiber.zh.md index fafa559ca9..9ed3e52618 100644 --- a/docs/cordis-api/fiber.zh.md +++ b/docs/cordis-api/fiber.zh.md @@ -36,7 +36,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L420) +[源码](../../vendor/cordis/src/fiber.ts#L415) ### ctx.fiber @@ -99,7 +99,7 @@ public state 当前生命周期状态;状态转换会发出 `internal/status`。 -[源码](../../vendor/cordis/src/fiber.ts#L192) +[源码](../../vendor/cordis/src/fiber.ts#L194) ### fiber.dispose @@ -110,7 +110,7 @@ public readonly dispose: () => Promise dispose 此 fiber:卸载插件,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L194) +[源码](../../vendor/cordis/src/fiber.ts#L196) ### fiber.store @@ -121,7 +121,7 @@ public store: Dict | undefined 加载期间所需服务实现的快照;其他情况下为 `undefined`。 -[源码](../../vendor/cordis/src/fiber.ts#L196) +[源码](../../vendor/cordis/src/fiber.ts#L198) ### fiber.inertia @@ -132,7 +132,7 @@ public inertia: Promise | undefined 当前正在进行的加载或卸载转换;如果没有此类转换,则为 undefined。 -[源码](../../vendor/cordis/src/fiber.ts#L198) +[源码](../../vendor/cordis/src/fiber.ts#L200) ### fiber.name @@ -143,7 +143,7 @@ get name() 插件的显示名称,继承自最近的具名祖先;如果不存在,则为 `'root'`。 -[源码](../../vendor/cordis/src/fiber.ts#L341) +[源码](../../vendor/cordis/src/fiber.ts#L336) ### fiber.assertActive() @@ -161,7 +161,7 @@ assertActive() **返回**:fiber 仍处于活动状态时不返回任何内容。 -[源码](../../vendor/cordis/src/fiber.ts#L356) +[源码](../../vendor/cordis/src/fiber.ts#L351) ### fiber.effect(execute, label?) @@ -192,7 +192,7 @@ effect(execute: () => Effect, label?: string): AsyncDisposable> **返回**一个用于撤销该作用的清理函数,并在清理完成后结算。 -[源码](../../vendor/cordis/src/fiber.ts#L420) +[源码](../../vendor/cordis/src/fiber.ts#L415) ### fiber.getEffects() @@ -209,7 +209,7 @@ getEffects() **返回**:每个带标签的活动作用对应一棵 `EffectMeta` 树。 -[源码](../../vendor/cordis/src/fiber.ts#L573) +[源码](../../vendor/cordis/src/fiber.ts#L568) ### fiber.await() @@ -227,7 +227,7 @@ async await() **返回**:进入稳定状态后的此 fiber。 -[源码](../../vendor/cordis/src/fiber.ts#L702) +[源码](../../vendor/cordis/src/fiber.ts#L704) ### fiber.restart() @@ -245,7 +245,7 @@ dispose 此插件,并立即使用其当前配置重新加载。 **返回**一个在重新加载完成后兑现的 promise。 -[源码](../../vendor/cordis/src/fiber.ts#L716) +[源码](../../vendor/cordis/src/fiber.ts#L718) ### fiber.update(config, noSave?) @@ -273,7 +273,7 @@ update(config: any, noSave = false) **返回**更新 waterfall 的结果;默认的重新启动操作返回一个 promise。 -[源码](../../vendor/cordis/src/fiber.ts#L734) +[源码](../../vendor/cordis/src/fiber.ts#L736) ## Effect diff --git a/docs/cordis-primer.i18n.yaml b/docs/cordis-primer.i18n.yaml index 12177e3d35..180ba85c01 100644 --- a/docs/cordis-primer.i18n.yaml +++ b/docs/cordis-primer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-primer.md -cordis-primer.md: c95909a4a1deab9407efedbb990ef13be6e43a16 -cordis-primer.zh.md: a18b8b37af19a610b71babbe5e67f96bb09e81b1 +cordis-primer.md: d1e7c5fd8eaaa89fe448d238359389d945cd6346 +cordis-primer.zh.md: d6ce0f2024f65b006c9505daffaa06a08bb56875 diff --git a/docs/cordis-primer.md b/docs/cordis-primer.md index c95909a4a1..d1e7c5fd8e 100644 --- a/docs/cordis-primer.md +++ b/docs/cordis-primer.md @@ -35,7 +35,7 @@ For single-decision events, short-circuiting is the design. A policy listener ca ## Loader Configuration -`@cordisjs/plugin-include` parses `!!js` into expression nodes, but the Loader interpolates only an entry's `config` before mounting the plugin. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, and `isolate`) remains literal; `disabled: !!js ...` is therefore a truthy object that always disables the entry. Use explicit config overlays when environment selection changes which plugins are mounted. +`@deepseek-ai/cordis-plugin-include` parses `!!js` into expression nodes. Loader interpolates only an entry's `config`, after declared injections activate, against that plugin context (`ctx.serviceName`); Include preserves nested row expressions until target activation. Entry metadata (`id`, `name`, `group`, `disabled`, `inject`, `intercept`, `isolate`) stays literal, so `disabled: !!js ...` always disables the entry. Use overlays when the environment selects plugins. ## Practical Rules diff --git a/docs/cordis-primer.zh.md b/docs/cordis-primer.zh.md index a18b8b37af..d6ce0f2024 100644 --- a/docs/cordis-primer.zh.md +++ b/docs/cordis-primer.zh.md @@ -39,7 +39,7 @@ Cordis 是 DeepSeek Harness SDK 底层以 vendor 方式引入的插件框架。 ## Loader 配置 -`@cordisjs/plugin-include` 将 `!!js` 解析为表达式节点,但 Loader 仅在挂载插件前对条目的 `config` 做插值。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept` 和 `isolate`)保持字面值;因此 `disabled: !!js ...` 是一个 truthy 对象,会始终禁用该条目。需要根据环境选择挂载哪些插件时,请使用显式的配置覆盖层。 +`@deepseek-ai/cordis-plugin-include` 将 `!!js` 解析为表达式节点。Loader 只在声明的注入激活后,基于该插件上下文(`ctx.serviceName`)插值条目的 `config`;Include 会保留嵌套行表达式,直到目标行激活。条目元数据(`id`、`name`、`group`、`disabled`、`inject`、`intercept`、`isolate`)保持字面值,因此 `disabled: !!js ...` 始终禁用该条目。由环境选择插件时,请使用 overlay。 ## 实践规则 diff --git a/docs/cordis-tutorial/01-first-plugin.i18n.yaml b/docs/cordis-tutorial/01-first-plugin.i18n.yaml index 9bf649ab29..cbb4aee438 100644 --- a/docs/cordis-tutorial/01-first-plugin.i18n.yaml +++ b/docs/cordis-tutorial/01-first-plugin.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/01-first-plugin.md -01-first-plugin.md: 260026329443f9a5b8860d11a6527dbd687eb44c -01-first-plugin.zh.md: 69dedb898c7ea29f99233f07126cd413fa0ddbe2 +01-first-plugin.md: 79df2f42df1f34a7ef32cc81607e1b926a1854ae +01-first-plugin.zh.md: 87cd7a96843d69d68fb95662e59bbfb9ebfc2a37 diff --git a/docs/cordis-tutorial/01-first-plugin.md b/docs/cordis-tutorial/01-first-plugin.md index 2600263294..79df2f42df 100644 --- a/docs/cordis-tutorial/01-first-plugin.md +++ b/docs/cordis-tutorial/01-first-plugin.md @@ -9,7 +9,7 @@ In the loader configuration used here, a Cordis plugin module named-exports an ` In your `tmp/cordis-tutorial` directory (see [setup](index.md#setup)), create `hello.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'hello' @@ -55,7 +55,7 @@ There is no framework bootstrap code in your file: a plugin describes what it co A function is the most common form, but Cordis accepts three: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' // 1. Function plugin (what you just wrote). export function apply(ctx: Context) {} @@ -92,4 +92,4 @@ One caveat worth knowing early: a config entry whose module cannot be **resolved Next: [Lifecycle and effects](02-lifecycle-and-effects.md) — what happens when a plugin unloads. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/01-first-plugin.zh.md b/docs/cordis-tutorial/01-first-plugin.zh.md index 69dedb898c..87cd7a9684 100644 --- a/docs/cordis-tutorial/01-first-plugin.zh.md +++ b/docs/cordis-tutorial/01-first-plugin.zh.md @@ -9,7 +9,7 @@ 在 `tmp/cordis-tutorial` 目录中(参见[环境设置](index.md#setup))创建 `hello.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'hello' @@ -55,7 +55,7 @@ hello from my first plugin 函数是最常见的形式,但 Cordis 接受三种形式: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' // 1. Function plugin (what you just wrote). export function apply(ctx: Context) {} @@ -92,4 +92,4 @@ export function apply(ctx: Context) { 下一章:[生命周期与 effect](02-lifecycle-and-effects.md):插件卸载时会发生什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml index 12793267e2..26ffcac268 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/02-lifecycle-and-effects.md -02-lifecycle-and-effects.md: 7b195b63a1e8730f27b9dd9af8af6a68a588cee9 -02-lifecycle-and-effects.zh.md: 4a3f83dedd5c95c7fcb5c1aebbbb8cb2e849b9cf +02-lifecycle-and-effects.md: 8e75708eb0cba1aceb7fa3dae5ae334995eb113f +02-lifecycle-and-effects.zh.md: bc4489e702ab58b5d1efd8a72fc693ee48af2624 diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.md b/docs/cordis-tutorial/02-lifecycle-and-effects.md index 7b195b63a1..8e75708eb0 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.md @@ -11,7 +11,7 @@ For a resource Cordis does not already manage — a timer, a connection, a watch Create `lifecycle.ts` in `tmp/cordis-tutorial`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'lifecycle-demo' @@ -95,4 +95,4 @@ One ordering caveat: disposers start in reverse registration order, but multiple Next: [Services](03-services.md) — how plugins share capabilities. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md index 4a3f83dedd..bc4489e702 100644 --- a/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md +++ b/docs/cordis-tutorial/02-lifecycle-and-effects.zh.md @@ -11,7 +11,7 @@ Cordis 插件可能因修改配置、热重载、显式资源释放或所需服 创建 `lifecycle.ts`,将它放在 `tmp/cordis-tutorial` 中: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'lifecycle-demo' @@ -95,4 +95,4 @@ PENDING → LOADING → ACTIVE → UNLOADING → DISPOSED 下一章:[服务](03-services.md):插件如何共享功能。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/03-services.i18n.yaml b/docs/cordis-tutorial/03-services.i18n.yaml index bdb7e19387..372e028a47 100644 --- a/docs/cordis-tutorial/03-services.i18n.yaml +++ b/docs/cordis-tutorial/03-services.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/03-services.md -03-services.md: 82b08b7b8a2ec8a6b340dd1fdc7fa3de98cedff9 -03-services.zh.md: ba4152454eb79a21b183b867c0ba2ef32cd43923 +03-services.md: 32007284be99ef46b4621089c9b3a80317e77189 +03-services.zh.md: d82be29aa69686b8dc10cc6a45a658683c017cbd diff --git a/docs/cordis-tutorial/03-services.md b/docs/cordis-tutorial/03-services.md index 82b08b7b8a..32007284be 100644 --- a/docs/cordis-tutorial/03-services.md +++ b/docs/cordis-tutorial/03-services.md @@ -9,9 +9,9 @@ A **service** is a named capability one plugin provides and other plugins consum Create `greeter.ts` in `tmp/cordis-tutorial`: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { greeter: GreeterService } @@ -37,7 +37,7 @@ export function apply(ctx: Context) { Two pieces work together: - **Runtime**: `super(ctx, 'greeter')` registers the instance under the name `greeter`. From then on, any plugin can reach it as `ctx.greeter`. The registration is an effect — unloading the provider removes the service. -- **Compile time**: the `declare module 'cordis'` block is TypeScript declaration merging. It adds `greeter` to the `Context` interface so `ctx.greeter` typechecks everywhere. It generates no code; without it the service still works at runtime, but consumers lose type safety. +- **Compile time**: the `declare module '@deepseek-ai/cordis'` block is TypeScript declaration merging. It adds `greeter` to the `Context` interface so `ctx.greeter` typechecks everywhere. It generates no code; without it the service still works at runtime, but consumers lose type safety. A `Service` subclass is itself a plugin (the class form from chapter 1), so `ctx.plugin(GreeterService)` mounts it like any other. @@ -46,7 +46,7 @@ A `Service` subclass is itself a plugin (the class form from chapter 1), so `ctx Create `consumer.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'consumer' export const inject = ['greeter'] @@ -95,4 +95,4 @@ Service names live in one flat namespace per application. Prefix or namespace yo Next: [Events](04-events.md) — communication without a shared service. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/03-services.zh.md b/docs/cordis-tutorial/03-services.zh.md index ba4152454e..d82be29aa6 100644 --- a/docs/cordis-tutorial/03-services.zh.md +++ b/docs/cordis-tutorial/03-services.zh.md @@ -9,9 +9,9 @@ 创建 `greeter.ts`,将它放在 `tmp/cordis-tutorial` 中: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { greeter: GreeterService } @@ -37,7 +37,7 @@ export function apply(ctx: Context) { 两部分协同工作: - **运行时**:`super(ctx, 'greeter')` 以名称 `greeter` 注册该实例。此后,任何插件都可以通过 `ctx.greeter` 访问它。注册属于 effect,卸载提供方时会移除该服务。 -- **编译时**:`declare module 'cordis'` 块使用 TypeScript 声明合并,把 `greeter` 加入 `Context` 接口,使 `ctx.greeter` 在各处都能通过类型检查。它不会生成代码;没有该声明时,服务在运行时仍能工作,但消费方会失去类型安全。 +- **编译时**:`declare module '@deepseek-ai/cordis'` 块使用 TypeScript 声明合并,把 `greeter` 加入 `Context` 接口,使 `ctx.greeter` 在各处都能通过类型检查。它不会生成代码;没有该声明时,服务在运行时仍能工作,但消费方会失去类型安全。 `Service` 子类本身就是插件(第 1 章介绍的类形态),因此 `ctx.plugin(GreeterService)` 会像挂载其他插件一样挂载它。 @@ -46,7 +46,7 @@ export function apply(ctx: Context) { 创建 `consumer.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'consumer' export const inject = ['greeter'] @@ -95,4 +95,4 @@ export function apply(ctx: Context) { 下一章:[事件](04-events.md):无需共享服务即可通信。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/04-events.i18n.yaml b/docs/cordis-tutorial/04-events.i18n.yaml index b453ffb1f6..4ce3fb9a65 100644 --- a/docs/cordis-tutorial/04-events.i18n.yaml +++ b/docs/cordis-tutorial/04-events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/04-events.md -04-events.md: e77641dffcb82fcb50a24ca2e3e764d152218094 -04-events.zh.md: 00cce854e9a54fddb594ffa8e306f60a725ac012 +04-events.md: db911b9a06d7304d73030a2020de3d465fde2f9c +04-events.zh.md: e0357b2c04785d52a54f839d1b8ff3ecccb027ed diff --git a/docs/cordis-tutorial/04-events.md b/docs/cordis-tutorial/04-events.md index e77641dffc..db911b9a06 100644 --- a/docs/cordis-tutorial/04-events.md +++ b/docs/cordis-tutorial/04-events.md @@ -9,9 +9,9 @@ Services support direct calls; **events** let a plugin announce something withou Create `stats.ts` in `tmp/cordis-tutorial` — a service that counts things and announces each change: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { stats: StatsService } @@ -46,7 +46,7 @@ The `interface Events` merge is the event-system twin of the `interface Context` Create `reporter.ts`: ```ts ignore-check -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from './stats.ts' export const name = 'reporter' @@ -96,9 +96,9 @@ Every harness event documents its mode in the generated reference on its owning Waterfall is the mode that powers interception. Each listener receives the arguments plus a `next()` continuation; it can transform what `next()` returns, or return without calling `next()` and short-circuit the rest of the chain — what the Cordis docs call the veto. Create `waterfall-demo.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { 'demo/transform'(input: string, next: () => Promise): Promise } @@ -141,4 +141,4 @@ The harness uses waterfalls for decisions that cooperating plugins may wrap or a Next: [Configuration](05-config.md) — plugin options from `cordis.yml`. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/04-events.zh.md b/docs/cordis-tutorial/04-events.zh.md index 00cce854e9..e0357b2c04 100644 --- a/docs/cordis-tutorial/04-events.zh.md +++ b/docs/cordis-tutorial/04-events.zh.md @@ -9,9 +9,9 @@ 创建 `stats.ts`,将它放在 `tmp/cordis-tutorial` 中。它是一项负责计数并在每次变化时发出通知的服务: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { stats: StatsService } @@ -46,7 +46,7 @@ export function apply(ctx: Context) { 创建 `reporter.ts`: ```ts ignore-check -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from './stats.ts' export const name = 'reporter' @@ -96,9 +96,9 @@ export function apply(ctx: Context) { waterfall 是实现拦截的模式。每个监听器都会收到参数和一个 `next()` continuation;它可以转换 `next()` 的返回值,也可以不调用 `next()` 就直接返回,从而短路链条的其余部分。Cordis 文档把后一种行为称为否决。创建 `waterfall-demo.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { 'demo/transform'(input: string, next: () => Promise): Promise } @@ -141,4 +141,4 @@ harness 使用 waterfall 处理协作插件可以包装或回答的决策:[`ag 下一章:[配置](05-config.md):来自 `cordis.yml` 的插件选项。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/05-config.i18n.yaml b/docs/cordis-tutorial/05-config.i18n.yaml index 7db45165c4..4e953918dd 100644 --- a/docs/cordis-tutorial/05-config.i18n.yaml +++ b/docs/cordis-tutorial/05-config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/05-config.md -05-config.md: 834bb140cc1ff976acc8f21c8f54a7fb02636eac -05-config.zh.md: f5cc6ac1ca4fa02eba6a1b015b9f6ae3b1a925fc +05-config.md: 2357f663135d6fc78a65f9d0952e0bc3f5eefae4 +05-config.zh.md: fbd94d179494ad0b6f73baff2ca525c786cc9e33 diff --git a/docs/cordis-tutorial/05-config.md b/docs/cordis-tutorial/05-config.md index 834bb140cc..2357f66313 100644 --- a/docs/cordis-tutorial/05-config.md +++ b/docs/cordis-tutorial/05-config.md @@ -9,8 +9,8 @@ Each `cordis.yml` entry can carry a `config` block, and the plugin declares a sc Create `config-demo.ts` in `tmp/cordis-tutorial`: ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' export const name = 'config-demo' @@ -81,4 +81,4 @@ The loader used in this repo supports a `!!js` tag for config values that must b Next: [Composition and HMR](06-composition-and-hmr.md) — treating `cordis.yml` as the application. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/05-config.zh.md b/docs/cordis-tutorial/05-config.zh.md index f5cc6ac1ca..fbd94d1794 100644 --- a/docs/cordis-tutorial/05-config.zh.md +++ b/docs/cordis-tutorial/05-config.zh.md @@ -9,8 +9,8 @@ 创建 `config-demo.ts`,并将其放在 `tmp/cordis-tutorial` 中: ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' export const name = 'config-demo' @@ -81,4 +81,4 @@ ValidationError: invalid config: 下一章:[组合与 HMR(热模块替换)](06-composition-and-hmr.md):将 `cordis.yml` 视为应用。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml index 3732651e58..6a79850803 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml +++ b/docs/cordis-tutorial/06-composition-and-hmr.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/06-composition-and-hmr.md -06-composition-and-hmr.md: a169d7a164be63c939e352e4e5b0bf9bce43da29 -06-composition-and-hmr.zh.md: 07ae46555c390d625a4397933e2ec5ac059bd270 +06-composition-and-hmr.md: 87ea26014657ae8c8199e1ebb486556c827d96ca +06-composition-and-hmr.zh.md: 830f55de7c1be351fe701cb068197543602619a7 diff --git a/docs/cordis-tutorial/06-composition-and-hmr.md b/docs/cordis-tutorial/06-composition-and-hmr.md index a169d7a164..87ea260146 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.md @@ -22,24 +22,24 @@ Groups nest a sub-list of entries that load and unload as one unit, and `isolate ## Hot module replacement -Because unloading releases effects ([chapter 2](02-lifecycle-and-effects.md)) and loading follows dependencies ([chapter 3](03-services.md)), HMR can replace a running plugin by unloading and loading it. The `@cordisjs/plugin-hmr` plugin watches your files and does exactly that on save. +Because unloading releases effects ([chapter 2](02-lifecycle-and-effects.md)) and loading follows dependencies ([chapter 3](03-services.md)), HMR can replace a running plugin by unloading and loading it. The `@deepseek-ai/cordis-plugin-hmr` plugin watches your files and does exactly that on save. In `tmp/cordis-tutorial`, write `cordis.yml`: ```yaml - id: logger - name: '@cordisjs/plugin-logger-console' + name: '@deepseek-ai/cordis-plugin-logger-console' - id: timer - name: '@cordisjs/plugin-timer' + name: '@deepseek-ai/cordis-plugin-timer' - id: hmr - name: '@cordisjs/plugin-hmr' + name: '@deepseek-ai/cordis-plugin-hmr' config: root: ['.'] - id: hello name: './hello.ts' ``` -Two support plugins joined the list: HMR logs through the Cordis logger service, so without a console exporter you would not see its messages, and it `inject`s the `timer` service for debouncing — without `@cordisjs/plugin-timer` it sits in PENDING forever, silently. That silence is the subject of the next section. +Two support plugins joined the list: HMR logs through the Cordis logger service, so without a console exporter you would not see its messages, and it `inject`s the `timer` service for debouncing — without `@deepseek-ai/cordis-plugin-timer` it sits in PENDING forever, silently. That silence is the subject of the next section. HMR reads Node's loader internals through the Loader's native helper. Run Cordis under tsx: @@ -65,7 +65,7 @@ The flip side of dependency-driven loading: a plugin whose `inject` names a serv You can see the states directly. Every context can enumerate the plugin registry; create `diagnose.ts`: ```ts -import { FiberState, type Context } from 'cordis' +import { FiberState, type Context } from '@deepseek-ai/cordis' export const name = 'diagnose' @@ -85,7 +85,7 @@ export function apply(ctx: Context) { And a plugin with an unsatisfiable dependency, `needs-timer.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'needs-timer' export const inject = ['timer'] @@ -106,8 +106,8 @@ Run it (plain `node --import tsx ../../vendor/cordis/bin.js`; stop with Ctrl-C): needs-timer is PENDING — a required service is missing ``` -`inject: ['timer']` has no provider. Add `- name: '@cordisjs/plugin-timer'` to the list and the plugin loads. When a plugin does nothing and reports nothing, inspect its fiber state. Iterating without the PENDING filter also shows the loader's own plugins (Loader, Include) as ACTIVE fibers because plugins mount the config file itself. +`inject: ['timer']` has no provider. Add `- name: '@deepseek-ai/cordis-plugin-timer'` to the list and the plugin loads. When a plugin does nothing and reports nothing, inspect its fiber state. Iterating without the PENDING filter also shows the loader's own plugins (Loader, Include) as ACTIVE fibers because plugins mount the config file itself. Next: [Into the harness](07-into-the-harness.md) — the same patterns against real harness services. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/06-composition-and-hmr.zh.md b/docs/cordis-tutorial/06-composition-and-hmr.zh.md index 07ae46555c..830f55de7c 100644 --- a/docs/cordis-tutorial/06-composition-and-hmr.zh.md +++ b/docs/cordis-tutorial/06-composition-and-hmr.zh.md @@ -22,24 +22,24 @@ Cordis 配置项除了 `name` 和 `config`,还接受其他元数据: ## 热模块替换 -卸载会释放 effect([第 2 章](02-lifecycle-and-effects.md)),加载则遵循依赖关系([第 3 章](03-services.md)),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。`@cordisjs/plugin-hmr` 插件会监视文件,并在保存时执行这一过程。 +卸载会释放 effect([第 2 章](02-lifecycle-and-effects.md)),加载则遵循依赖关系([第 3 章](03-services.md)),因此 HMR 可以先卸载、再加载,以替换正在运行的插件。`@deepseek-ai/cordis-plugin-hmr` 插件会监视文件,并在保存时执行这一过程。 在 `tmp/cordis-tutorial` 中编写 `cordis.yml`: ```yaml - id: logger - name: '@cordisjs/plugin-logger-console' + name: '@deepseek-ai/cordis-plugin-logger-console' - id: timer - name: '@cordisjs/plugin-timer' + name: '@deepseek-ai/cordis-plugin-timer' - id: hmr - name: '@cordisjs/plugin-hmr' + name: '@deepseek-ai/cordis-plugin-hmr' config: root: ['.'] - id: hello name: './hello.ts' ``` -列表中增加了两个辅助插件:HMR 通过 Cordis logger 服务记录日志,因此没有控制台导出器时看不到其消息;它还会 `inject` `timer` 服务来实现去抖,如果没有 `@cordisjs/plugin-timer`,它就会永远停在 PENDING,而且不发出任何提示。下一节就讨论这种静默状态。 +列表中增加了两个辅助插件:HMR 通过 Cordis logger 服务记录日志,因此没有控制台导出器时看不到其消息;它还会 `inject` `timer` 服务来实现去抖,如果没有 `@deepseek-ai/cordis-plugin-timer`,它就会永远停在 PENDING,而且不发出任何提示。下一节就讨论这种静默状态。 HMR 通过 Loader 的原生辅助工具读取 Node 的 loader 内部结构。请在 tsx 下运行 Cordis: @@ -65,7 +65,7 @@ hello from my EDITED plugin 你可以直接查看这些状态。每个上下文都能枚举插件注册表;创建 `diagnose.ts`: ```ts -import { FiberState, type Context } from 'cordis' +import { FiberState, type Context } from '@deepseek-ai/cordis' export const name = 'diagnose' @@ -85,7 +85,7 @@ export function apply(ctx: Context) { 再创建一个依赖无法满足的插件 `needs-timer.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'needs-timer' export const inject = ['timer'] @@ -106,8 +106,8 @@ export function apply(ctx: Context) { needs-timer is PENDING — a required service is missing ``` -`inject: ['timer']` 没有提供方。向列表添加 `- name: '@cordisjs/plugin-timer'` 后,插件就会加载。如果插件既不执行任何操作,也不报告任何内容,请检查其 fiber 状态。不加 PENDING 过滤条件进行迭代时,还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE,因为配置文件本身也是通过插件挂载的。 +`inject: ['timer']` 没有提供方。向列表添加 `- name: '@deepseek-ai/cordis-plugin-timer'` 后,插件就会加载。如果插件既不执行任何操作,也不报告任何内容,请检查其 fiber 状态。不加 PENDING 过滤条件进行迭代时,还会看到 loader 自身的插件(Loader、Include)处于 ACTIVE,因为配置文件本身也是通过插件挂载的。 下一章:[进入 harness](07-into-the-harness.md):把相同模式用于真实的 harness 服务。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml index 8fb1893fca..fbc6ef1cde 100644 --- a/docs/cordis-tutorial/07-into-the-harness.i18n.yaml +++ b/docs/cordis-tutorial/07-into-the-harness.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/07-into-the-harness.md -07-into-the-harness.md: 69133786f58541b015aed080f4ac8fb2a7e488c0 -07-into-the-harness.zh.md: bc9c61da984e3eb691eb6bfbe59ae556823e82de +07-into-the-harness.md: 2d3c23f9f7f7fc6bd6cabd4e7e68ebfc46e20665 +07-into-the-harness.zh.md: 45dc0ee6f07ab3b0275499cd4ab0c436eebdddef diff --git a/docs/cordis-tutorial/07-into-the-harness.md b/docs/cordis-tutorial/07-into-the-harness.md index 69133786f5..2d3c23f9f7 100644 --- a/docs/cordis-tutorial/07-into-the-harness.md +++ b/docs/cordis-tutorial/07-into-the-harness.md @@ -9,7 +9,7 @@ This chapter registers a model-callable tool with the harness's `tools` service, Create `greet-tool.ts` in `tmp/cordis-tutorial`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import { CallId } from '@deepseek-ai/dsh-llm' @@ -53,7 +53,7 @@ Every pattern here is from the earlier chapters: `inject: ['tools']` ([chapter 3 Create `tool-logger.ts` — a separate plugin that watches every tool call in the app through the harness's `tools/result` event: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-tools' export const name = 'tool-logger' @@ -104,4 +104,4 @@ Where to go next: - The generated `cordis-surface` regions on the [subsystem pages](../subsystems/core.md) — everything you can inject and listen to, each on its owning page. - [Architecture](../architecture.md) — the system map these plugins live in. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/07-into-the-harness.zh.md b/docs/cordis-tutorial/07-into-the-harness.zh.md index bc9c61da98..45dc0ee6f0 100644 --- a/docs/cordis-tutorial/07-into-the-harness.zh.md +++ b/docs/cordis-tutorial/07-into-the-harness.zh.md @@ -9,7 +9,7 @@ 创建 `greet-tool.ts`,将它放在 `tmp/cordis-tutorial` 中: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import { CallId } from '@deepseek-ai/dsh-llm' @@ -53,7 +53,7 @@ export function apply(ctx: Context) { 创建 `tool-logger.ts`。这是一个独立插件,通过 harness 的 `tools/result` 事件观察应用中的每次工具调用: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-tools' export const name = 'tool-logger' @@ -104,4 +104,4 @@ logger 会先触发:`tools/result` 在结果物化过程中发出,发生在 - [子系统页面](../subsystems/core.md)上生成的 `cordis-surface` 区块:可以注入和监听的所有内容,各在其所属页面上。 - [架构](../architecture.md):这些插件所处的系统地图。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/index.i18n.yaml b/docs/cordis-tutorial/index.i18n.yaml index 719e949ffe..06857ab177 100644 --- a/docs/cordis-tutorial/index.i18n.yaml +++ b/docs/cordis-tutorial/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/cordis-tutorial/index.md -index.md: 307c12854b3075cfd4dd5ea8a19806c58b4e998d -index.zh.md: a0107b7d15272e6ef8d526b9c0e03a99275644d6 +index.md: fb700344e6d07d3864655009d2edac15ee9eede8 +index.zh.md: a68e931d81e745164d8f9a5dc7ec9aec4cd0e590 diff --git a/docs/cordis-tutorial/index.md b/docs/cordis-tutorial/index.md index 307c12854b..fb700344e6 100644 --- a/docs/cordis-tutorial/index.md +++ b/docs/cordis-tutorial/index.md @@ -13,7 +13,7 @@ If you want the condensed concept reference instead of a walkthrough, read the [ You need a clone of this repository with dependencies installed — the [quick start](../user/guide/quickstart.md) covers prerequisites. No API key is needed for this tutorial; every example runs keylessly. ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install ``` @@ -50,9 +50,9 @@ That one-file launcher (see [vendor/cordis/bin.js](../../vendor/cordis/bin.js)) The examples use three TypeScript features beyond ordinary modern JavaScript: - **Type annotations** describe values without changing runtime behavior: `ctx: Context` says that `ctx` has the Cordis context API, `who: string` accepts text, and `string[]` means an array of strings. -- **`import type { Context } from 'cordis'`** imports only type information. It vanishes at runtime, so a plugin file that needs `Context` solely for annotations adds no runtime dependency. -- **Declaration merging** (`declare module 'cordis' { ... }`) adds your entries to interfaces that Cordis already declares — for example the type of a new `ctx.greeter` property or event name. It generates no runtime wiring; the plugin separately provides the service or emits the event. Chapter 3 shows the pattern in full. +- **`import type { Context } from '@deepseek-ai/cordis'`** imports only type information. It vanishes at runtime, so a plugin file that needs `Context` solely for annotations adds no runtime dependency. +- **Declaration merging** (`declare module '@deepseek-ai/cordis' { ... }`) adds your entries to interfaces that Cordis already declares — for example the type of a new `ctx.greeter` property or event name. It generates no runtime wiring; the plugin separately provides the service or emits the event. Chapter 3 shows the pattern in full. Chapter 5 also uses an `interface` to describe a configuration object's fields and a generic type such as `Schema` to say which object fields a schema validates. You can copy those declarations as shown; the surrounding text explains what each one connects. -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/cordis-tutorial/index.zh.md b/docs/cordis-tutorial/index.zh.md index a0107b7d15..a68e931d81 100644 --- a/docs/cordis-tutorial/index.zh.md +++ b/docs/cordis-tutorial/index.zh.md @@ -13,7 +13,7 @@ Cordis 是 DeepSeek Harness SDK 底层的插件框架:它是一个小型运行 你需要克隆本仓库并安装依赖,具体前置条件见[快速入门](../user/guide/quickstart.md)。本教程不需要 API 密钥;所有示例均可在无密钥环境中运行。 ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install ``` @@ -50,9 +50,9 @@ node --import tsx ../../vendor/cordis/bin.js 这些示例使用了普通现代 JavaScript 之外的三项 TypeScript 功能: - **类型注解** 描述值,但不会改变运行时行为:`ctx: Context` 表示 `ctx` 具备 Cordis 上下文 API,`who: string` 接受文本,而 `string[]` 表示字符串数组。 -- **`import type { Context } from 'cordis'`** 只导入类型信息。它在运行时会消失,因此仅为类型注解使用 `Context` 的插件文件不会增加运行时依赖。 -- **声明合并**(`declare module 'cordis' { ... }`)会为 Cordis 已经声明的接口添加你的条目,例如新 `ctx.greeter` 属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件。第 3 章会完整展示该模式。 +- **`import type { Context } from '@deepseek-ai/cordis'`** 只导入类型信息。它在运行时会消失,因此仅为类型注解使用 `Context` 的插件文件不会增加运行时依赖。 +- **声明合并**(`declare module '@deepseek-ai/cordis' { ... }`)会为 Cordis 已经声明的接口添加你的条目,例如新 `ctx.greeter` 属性的类型或事件名称。它不会生成任何运行时接线;插件必须另行提供服务或发出事件。第 3 章会完整展示该模式。 第 5 章还会使用 `interface` 描述配置对象的字段,并使用 `Schema` 这类泛型表示 schema 校验哪些对象字段。你可以直接照写这些声明;周围的正文会解释每项声明连接了什么。 -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) diff --git a/docs/development.i18n.yaml b/docs/development.i18n.yaml index da29debe08..9d8d8fba47 100644 --- a/docs/development.i18n.yaml +++ b/docs/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/development.md -development.md: 8e565f21c6e2ede7dab7dbda3c4b18b77ce0920f -development.zh.md: d9c0fbfbb663334b8f7e2ca11d4e8d9a0652c22e +development.md: 7d6ca74df1311560b0d61444c92e74927639ca31 +development.zh.md: 1461717cf496b1863f2f0c47ad4b42990dc789e9 diff --git a/docs/development.md b/docs/development.md index 8e565f21c6..7d6ca74df1 100644 --- a/docs/development.md +++ b/docs/development.md @@ -129,7 +129,7 @@ The root [contributor instructions](../AGENTS.md#commands) summarize common comm The one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`: ```sh -pnpm run demo:headless "summarize this workspace" +pnpm dsh --profile headless "summarize this workspace" ``` The self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`): diff --git a/docs/development.zh.md b/docs/development.zh.md index d9c0fbfbb6..1461717cf4 100644 --- a/docs/development.zh.md +++ b/docs/development.zh.md @@ -129,7 +129,7 @@ keyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若 单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`: ```sh -pnpm run demo:headless "summarize this workspace" +pnpm dsh --profile headless "summarize this workspace" ``` 自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`): diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 268fa1c8d0..2d2341925b 100644 --- a/docs/event-producer-consumer.i18n.yaml +++ b/docs/event-producer-consumer.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/event-producer-consumer.md -event-producer-consumer.md: d2e5cd66c2406cf84320fc1c0537d580214dd93d -event-producer-consumer.zh.md: 12ac194d68ed6121cc0b2c5e9bf2c7f77944f57b +event-producer-consumer.md: 55a57480e0311aa047e9b5f0f90b6457fc9a007f +event-producer-consumer.zh.md: c84c621befefdab7e668d64e90dcb14e28fd74ea diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index d2e5cd66c2..55a57480e0 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -8,7 +8,7 @@ This matrix shows which packages dispatch each harness-owned event and which pac | Event | Mode | Declared in | Dispatchers | Listeners | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-session`](../packages/goal/goal-session) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | @@ -30,19 +30,19 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:97`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:106`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | -| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 12ac194d68..c84c621bef 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -10,7 +10,7 @@ | 事件 | 模式 | 声明位置 | 派发方 | 监听方 | | --- | --- | --- | --- | --- | | `agent-loop/config-start-failed` | `emit` | [`packages/core/agent-loop/src/index.ts:182`](../packages/core/agent-loop/src/index.ts) | [`agent-loop`](../packages/core/agent-loop) (`events.dispatch`) | - | -| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`goal-session`](../packages/goal/goal-session) | +| `agent/created` | `emit` | [`packages/core/agent/src/runtime-types.ts:159`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-presets`](../packages/preset/agent-presets), [`goal-session`](../packages/goal/goal-session) | | `agent/disposed` | `emit` | [`packages/core/agent/src/runtime-types.ts:168`](../packages/core/agent/src/runtime-types.ts) | [`agent`](../packages/core/agent) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | | `agent/error` | `emit` | [`packages/core/agent/src/runtime-types.ts:290`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), `apiproxy`, [`goal-session`](../packages/goal/goal-session), [`session-telemetry`](../packages/session/session-telemetry) | | `agent/inbox/claimed` | `emit` | [`packages/core/agent/src/runtime-types.ts:197`](../packages/core/agent/src/runtime-types.ts) | [`agent-loop`](../packages/core/agent-loop) (`emit`) | [`acp`](../packages/acp/acp), [`goal-session`](../packages/goal/goal-session), [`subagent`](../packages/subagent/subagent) | @@ -32,19 +32,19 @@ | `goal/changed` | `emit` | [`packages/goal/goal/src/domain.ts:114`](../packages/goal/goal/src/domain.ts) | [`goal`](../packages/goal/goal) (`emit`) | [`goal-session`](../packages/goal/goal-session) | | `llm/adapters-updated` | `emit` | [`packages/llm/llm/src/index.ts:75`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`events.dispatch`) | `apiproxy`, [`llm`](../packages/llm/llm) | | `llm/stream` | `waterfall` | [`packages/llm/llm/src/index.ts:64`](../packages/llm/llm/src/index.ts) | [`llm`](../packages/llm/llm) (`waterfall`) | [`agent-loop`](../packages/core/agent-loop), [`llm`](../packages/llm/llm), [`llm-replay`](../packages/support/llm-replay), [`session-checkpoint-policy`](../packages/session/session-checkpoint-policy), [`session-title`](../packages/session/session-title) | -| `session/created` | `emit` | [`packages/core/session/src/index.ts:74`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | -| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:84`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | -| `session/event` | `emit` | [`packages/core/session/src/index.ts:96`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | -| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:105`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | +| `session/created` | `emit` | [`packages/core/session/src/index.ts:75`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | `apiproxy`, [`compact`](../packages/compact/compact), [`goal`](../packages/goal/goal), [`hook-protocol`](../packages/hooks/hook-protocol), [`llm-retry`](../packages/llm/llm-retry), [`permission`](../packages/interaction/permission), [`plan-mode`](../packages/plan/plan-mode), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| `session/disposed` | `emit` | [`packages/core/session/src/index.ts:85`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`session-persistence`](../packages/session/session-persistence), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-title`](../packages/session/session-title) | +| `session/event` | `emit` | [`packages/core/session/src/index.ts:97`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`acp`](../packages/acp/acp), [`agent-loop`](../packages/core/agent-loop), `apiproxy`, [`compact`](../packages/compact/compact), [`compact-basic`](../packages/compact/compact-basic), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`hook-protocol`](../packages/hooks/hook-protocol), [`loader-smoke`](../packages/support/loader-smoke), `server`, [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`session-telemetry`](../packages/session/session-telemetry), [`session-telemetry-otel`](../packages/session/session-telemetry-otel), [`session-title`](../packages/session/session-title), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval), [`workspace-context`](../packages/context/workspace-context) | +| `session/flush` | `parallel` | [`packages/core/session/src/index.ts:106`](../packages/core/session/src/index.ts) | [`session`](../packages/core/session) (`events.dispatch`) | [`session-persistence`](../packages/session/session-persistence), [`session-telemetry`](../packages/session/session-telemetry) | | `settings/document-updated` | `emit` | [`packages/settings/settings/src/index.ts:170`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | `apiproxy` | | `settings/updated` | `emit` | [`packages/settings/settings/src/index.ts:157`](../packages/settings/settings/src/index.ts) | [`settings`](../packages/settings/settings) (`events.dispatch`) | [`settings`](../packages/settings/settings) | | `skills/change` | `emit` | [`packages/skill/skill/src/index.ts:297`](../packages/skill/skill/src/index.ts) | [`skill`](../packages/skill/skill) (`events.dispatch`) | - | -| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:162`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | -| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:136`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:142`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | -| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:153`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | -| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:29`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`system-prompt`](../packages/core/system-prompt) | -| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:35`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:165`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), `server`, [`subagent`](../packages/subagent/subagent) | +| `subagent/provider-added` | `emit` | [`packages/subagent/subagent/src/index.ts:139`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`emit`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/provider-removed` | `emit` | [`packages/subagent/subagent/src/index.ts:145`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`subagent`](../packages/subagent/subagent), [`tool-subagent`](../packages/subagent/tool-subagent) | +| `subagent/start` | `emit` | [`packages/subagent/subagent/src/index.ts:156`](../packages/subagent/subagent/src/index.ts) | [`subagent`](../packages/subagent/subagent) (`events.dispatch`) | [`hooks-claude`](../packages/hooks/hooks-claude), [`subagent`](../packages/subagent/subagent) | +| `system-prompt/assemble` | `waterfall` | [`packages/core/system-prompt/src/index.ts:31`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`waterfall`) | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`system-prompt`](../packages/core/system-prompt) | +| `system-prompt/change` | `emit` | [`packages/core/system-prompt/src/index.ts:37`](../packages/core/system-prompt/src/index.ts) | [`system-prompt`](../packages/core/system-prompt) (`emit`) | - | | `telemetry/record` | `waterfall` | [`packages/session/session-telemetry/src/index.ts:43`](../packages/session/session-telemetry/src/index.ts) | [`session-telemetry`](../packages/session/session-telemetry) (`waterfall`) | - | | `tools/change` | `emit` | [`packages/core/tools/src/index.ts:193`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`emit`) | - | | `tools/code-dispatch-log` | `waterfall` | [`packages/core/tools/src/index.ts:175`](../packages/core/tools/src/index.ts) | [`tools`](../packages/core/tools) (`waterfall`) | [`spill-policy`](../packages/spill/spill-policy) | diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 77fcfca867..bfcfb4f693 100644 --- a/docs/module-graph.i18n.yaml +++ b/docs/module-graph.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/module-graph.md -module-graph.md: b596cb564943e321ff42cd59855bd7dc10981319 -module-graph.zh.md: b64f30c2df1eedb1a1fa106b79710558b765fdde +module-graph.md: 84fcd63100fb16a0ac26bc8f2f33fdd65bcf4e91 +module-graph.zh.md: 7aa6a57be53fbc74ed3017dd9d06400f176b99a6 diff --git a/docs/module-graph.md b/docs/module-graph.md index b596cb5649..84fcd63100 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -133,6 +133,7 @@ flowchart TD end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] + pkg_cmdline["cmdline"] end subgraph group_bundle["packages/bundle"] pkg_base["base"] @@ -166,6 +167,7 @@ flowchart TD pkg_client_ui_slash["client-ui-slash"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] + pkg_client_ui_task["client-ui-task"] pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_tool["client-ui-tool"] pkg_client_ui_trajectory["client-ui-trajectory"] @@ -199,6 +201,7 @@ flowchart TD end subgraph group_feedback["packages/feedback"] pkg_command_feedback["command-feedback"] + pkg_message_feedback["message-feedback"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] @@ -253,7 +256,6 @@ flowchart TD pkg_telemetry["telemetry"] end subgraph group_self_modification["packages/self-modification"] - pkg_repository_plugin["repository-plugin"] pkg_tool_cordis["tool-cordis"] end subgraph group_session["packages/session"] @@ -314,6 +316,7 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_llm_mock_server --> pkg_invariants + pkg_cmdline --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants @@ -398,9 +401,6 @@ flowchart TD pkg_client_ui_settings --> pkg_client_ui_primitives pkg_client_ui_settings --> pkg_client_ui_slots pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_primitives - pkg_client_ui_trajectory --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_environment @@ -515,12 +515,6 @@ flowchart TD pkg_host_directory_picker_native --> pkg_client_ui_slots pkg_host_directory_picker_native --> pkg_client_ui_workspace pkg_host_directory_picker_native --> pkg_invariants - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_paths - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_invariants @@ -572,8 +566,13 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_invariants + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_storage_domain + pkg_message_feedback --> pkg_type_meta pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver @@ -593,6 +592,14 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_paths + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -702,11 +709,6 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants pkg_tmux_context --> pkg_agent pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants @@ -717,7 +719,10 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_session_telemetry pkg_command_feedback --> pkg_user_id + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -793,6 +798,7 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy + pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -829,6 +835,8 @@ flowchart TD pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm + pkg_subagent --> pkg_sandbox + pkg_subagent --> pkg_sandbox_policy pkg_subagent --> pkg_scope pkg_subagent --> pkg_session pkg_subagent --> pkg_session_persistence @@ -836,6 +844,7 @@ flowchart TD pkg_subagent --> pkg_session_projection_cache pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools + pkg_subagent --> pkg_user_approval pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -889,6 +898,18 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants + pkg_client_ui_trajectory --> pkg_agent + pkg_client_ui_trajectory --> pkg_client_locale + pkg_client_ui_trajectory --> pkg_client_runtime + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_compact + pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -923,6 +944,7 @@ flowchart TD pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_subprocess + pkg_mcp_client --> pkg_timeout pkg_mcp_client --> pkg_tools pkg_tool_bash_persistent --> pkg_agent pkg_tool_bash_persistent --> pkg_invariants @@ -1018,12 +1040,10 @@ flowchart TD pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm - pkg_subagent_inprocess --> pkg_sandbox_policy pkg_subagent_inprocess --> pkg_session pkg_subagent_inprocess --> pkg_subagent pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools - pkg_subagent_inprocess --> pkg_user_approval pkg_tool_subagent --> pkg_agent pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_llm @@ -1069,10 +1089,6 @@ flowchart TD pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent - pkg_repository_plugin --> pkg_invariants - pkg_repository_plugin --> pkg_mcp_client - pkg_repository_plugin --> pkg_paths - pkg_repository_plugin --> pkg_skill_local pkg_tool_ralph --> pkg_agent pkg_tool_ralph --> pkg_invariants pkg_tool_ralph --> pkg_llm @@ -1158,6 +1174,12 @@ flowchart TD pkg_client_ui_subagent --> pkg_invariants pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_task --> pkg_client_locale + pkg_client_ui_task --> pkg_client_runtime + pkg_client_ui_task --> pkg_client_ui_conversation + pkg_client_ui_task --> pkg_client_ui_primitives + pkg_client_ui_task --> pkg_client_ui_slots + pkg_client_ui_task --> pkg_invariants pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1256,6 +1278,7 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | +| [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | @@ -1296,7 +1319,6 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | @@ -1324,7 +1346,6 @@ flowchart TD | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1338,11 +1359,12 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | +| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | @@ -1369,10 +1391,10 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1386,12 +1408,12 @@ flowchart TD | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | @@ -1401,13 +1423,15 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/interaction/user-interaction) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/self-modification/tool-cordis) | `self-modification` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | @@ -1422,7 +1446,7 @@ flowchart TD | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`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), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`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/interaction/user-approval) | +| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`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) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | @@ -1430,7 +1454,6 @@ flowchart TD | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | @@ -1443,6 +1466,7 @@ flowchart TD | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`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), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-task`](../packages/client/ui-task) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index b64f30c2df..7aa6a57be5 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -135,6 +135,7 @@ flowchart TD end subgraph group_boot["packages/boot"] pkg_app_boot["app-boot"] + pkg_cmdline["cmdline"] end subgraph group_bundle["packages/bundle"] pkg_base["base"] @@ -168,6 +169,7 @@ flowchart TD pkg_client_ui_slash["client-ui-slash"] pkg_client_ui_slots["client-ui-slots"] pkg_client_ui_subagent["client-ui-subagent"] + pkg_client_ui_task["client-ui-task"] pkg_client_ui_theme["client-ui-theme"] pkg_client_ui_tool["client-ui-tool"] pkg_client_ui_trajectory["client-ui-trajectory"] @@ -201,6 +203,7 @@ flowchart TD end subgraph group_feedback["packages/feedback"] pkg_command_feedback["command-feedback"] + pkg_message_feedback["message-feedback"] end subgraph group_guard["packages/guard"] pkg_repeat_tool_guard["repeat-tool-guard"] @@ -255,7 +258,6 @@ flowchart TD pkg_telemetry["telemetry"] end subgraph group_self_modification["packages/self-modification"] - pkg_repository_plugin["repository-plugin"] pkg_tool_cordis["tool-cordis"] end subgraph group_session["packages/session"] @@ -316,6 +318,7 @@ flowchart TD pkg_timeout --> pkg_invariants pkg_scope --> pkg_invariants pkg_llm_mock_server --> pkg_invariants + pkg_cmdline --> pkg_invariants pkg_base --> pkg_invariants pkg_client_modules --> pkg_invariants pkg_client_schema_form --> pkg_invariants @@ -400,9 +403,6 @@ flowchart TD pkg_client_ui_settings --> pkg_client_ui_primitives pkg_client_ui_settings --> pkg_client_ui_slots pkg_client_ui_settings --> pkg_invariants - pkg_client_ui_trajectory --> pkg_client_runtime - pkg_client_ui_trajectory --> pkg_client_ui_primitives - pkg_client_ui_trajectory --> pkg_invariants pkg_credentials_local --> pkg_atomic_write pkg_credentials_local --> pkg_credentials pkg_credentials_local --> pkg_environment @@ -517,12 +517,6 @@ flowchart TD pkg_host_directory_picker_native --> pkg_client_ui_slots pkg_host_directory_picker_native --> pkg_client_ui_workspace pkg_host_directory_picker_native --> pkg_invariants - pkg_agent_presets --> pkg_atomic_write - pkg_agent_presets --> pkg_invariants - pkg_agent_presets --> pkg_paths - pkg_agent_presets --> pkg_scope - pkg_agent_presets --> pkg_session - pkg_agent_presets --> pkg_settings pkg_persona --> pkg_invariants pkg_persona --> pkg_system_prompt pkg_sandbox --> pkg_invariants @@ -574,8 +568,13 @@ flowchart TD pkg_time_context --> pkg_agent pkg_time_context --> pkg_invariants pkg_time_context --> pkg_session - pkg_host_apiproxy --> pkg_agent_presets - pkg_host_apiproxy --> pkg_invariants + pkg_message_feedback --> pkg_brand + pkg_message_feedback --> pkg_invariants + pkg_message_feedback --> pkg_llm + pkg_message_feedback --> pkg_session + pkg_message_feedback --> pkg_session_persistence + pkg_message_feedback --> pkg_storage_domain + pkg_message_feedback --> pkg_type_meta pkg_host_directory_picker_auto --> pkg_host_directory_picker_browse pkg_host_directory_picker_auto --> pkg_host_directory_picker_native pkg_host_directory_picker_auto --> pkg_host_webserver @@ -595,6 +594,14 @@ flowchart TD pkg_user_interaction --> pkg_agent pkg_user_interaction --> pkg_invariants pkg_user_interaction --> pkg_llm + pkg_agent_presets --> pkg_agent + pkg_agent_presets --> pkg_atomic_write + pkg_agent_presets --> pkg_invariants + pkg_agent_presets --> pkg_paths + pkg_agent_presets --> pkg_scope + pkg_agent_presets --> pkg_session + pkg_agent_presets --> pkg_settings + pkg_agent_presets --> pkg_system_prompt pkg_pty --> pkg_agent pkg_pty --> pkg_brand pkg_pty --> pkg_invariants @@ -704,11 +711,6 @@ flowchart TD pkg_headless --> pkg_invariants pkg_headless --> pkg_llm pkg_headless --> pkg_session - pkg_client_test_runtime --> pkg_client_runtime - pkg_client_test_runtime --> pkg_client_ui_slots - pkg_client_test_runtime --> pkg_client_web_react - pkg_client_test_runtime --> pkg_host_apiproxy - pkg_client_test_runtime --> pkg_invariants pkg_tmux_context --> pkg_agent pkg_tmux_context --> pkg_bash pkg_tmux_context --> pkg_invariants @@ -719,7 +721,10 @@ flowchart TD pkg_command_feedback --> pkg_commands pkg_command_feedback --> pkg_invariants pkg_command_feedback --> pkg_session + pkg_command_feedback --> pkg_session_telemetry pkg_command_feedback --> pkg_user_id + pkg_host_apiproxy --> pkg_agent_presets + pkg_host_apiproxy --> pkg_invariants pkg_permission --> pkg_bash pkg_permission --> pkg_commands pkg_permission --> pkg_invariants @@ -795,6 +800,7 @@ flowchart TD pkg_fs_sandbox --> pkg_invariants pkg_fs_sandbox --> pkg_sandbox pkg_fs_sandbox --> pkg_sandbox_policy + pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -831,6 +837,8 @@ flowchart TD pkg_subagent --> pkg_brand pkg_subagent --> pkg_invariants pkg_subagent --> pkg_llm + pkg_subagent --> pkg_sandbox + pkg_subagent --> pkg_sandbox_policy pkg_subagent --> pkg_scope pkg_subagent --> pkg_session pkg_subagent --> pkg_session_persistence @@ -838,6 +846,7 @@ flowchart TD pkg_subagent --> pkg_session_projection_cache pkg_subagent --> pkg_tasks pkg_subagent --> pkg_tools + pkg_subagent --> pkg_user_approval pkg_tool_web --> pkg_invariants pkg_tool_web --> pkg_llm pkg_tool_web --> pkg_system_prompt @@ -891,6 +900,18 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_client_test_runtime --> pkg_client_runtime + pkg_client_test_runtime --> pkg_client_ui_slots + pkg_client_test_runtime --> pkg_client_web_react + pkg_client_test_runtime --> pkg_host_apiproxy + pkg_client_test_runtime --> pkg_invariants + pkg_client_ui_trajectory --> pkg_agent + pkg_client_ui_trajectory --> pkg_client_locale + pkg_client_ui_trajectory --> pkg_client_runtime + pkg_client_ui_trajectory --> pkg_client_ui_primitives + pkg_client_ui_trajectory --> pkg_compact + pkg_client_ui_trajectory --> pkg_invariants + pkg_client_ui_trajectory --> pkg_tools pkg_session_reference --> pkg_agent pkg_session_reference --> pkg_compact pkg_session_reference --> pkg_invariants @@ -925,6 +946,7 @@ flowchart TD pkg_mcp_client --> pkg_invariants pkg_mcp_client --> pkg_llm pkg_mcp_client --> pkg_subprocess + pkg_mcp_client --> pkg_timeout pkg_mcp_client --> pkg_tools pkg_tool_bash_persistent --> pkg_agent pkg_tool_bash_persistent --> pkg_invariants @@ -1020,12 +1042,10 @@ flowchart TD pkg_subagent_inprocess --> pkg_agent pkg_subagent_inprocess --> pkg_invariants pkg_subagent_inprocess --> pkg_llm - pkg_subagent_inprocess --> pkg_sandbox_policy pkg_subagent_inprocess --> pkg_session pkg_subagent_inprocess --> pkg_subagent pkg_subagent_inprocess --> pkg_system_prompt pkg_subagent_inprocess --> pkg_tools - pkg_subagent_inprocess --> pkg_user_approval pkg_tool_subagent --> pkg_agent pkg_tool_subagent --> pkg_invariants pkg_tool_subagent --> pkg_llm @@ -1071,10 +1091,6 @@ flowchart TD pkg_sdk_protocol --> pkg_llm pkg_sdk_protocol --> pkg_session pkg_sdk_protocol --> pkg_subagent - pkg_repository_plugin --> pkg_invariants - pkg_repository_plugin --> pkg_mcp_client - pkg_repository_plugin --> pkg_paths - pkg_repository_plugin --> pkg_skill_local pkg_tool_ralph --> pkg_agent pkg_tool_ralph --> pkg_invariants pkg_tool_ralph --> pkg_llm @@ -1160,6 +1176,12 @@ flowchart TD pkg_client_ui_subagent --> pkg_invariants pkg_client_ui_subagent --> pkg_subagent pkg_client_ui_subagent --> pkg_token_meter + pkg_client_ui_task --> pkg_client_locale + pkg_client_ui_task --> pkg_client_runtime + pkg_client_ui_task --> pkg_client_ui_conversation + pkg_client_ui_task --> pkg_client_ui_primitives + pkg_client_ui_task --> pkg_client_ui_slots + pkg_client_ui_task --> pkg_invariants pkg_client_ui_tool --> pkg_client_locale pkg_client_ui_tool --> pkg_client_runtime pkg_client_ui_tool --> pkg_client_ui_conversation @@ -1246,7 +1268,7 @@ flowchart TD pkg_acp_demo --> pkg_workspace_context ``` -| 包 | 分组 | 依赖项 | +| Package | Group | Depends on | | --- | --- | --- | | [`invariants`](../packages/support/invariants) | `support` | — | | [`atomic-write`](../packages/util/atomic-write) | `util` | [`invariants`](../packages/support/invariants) | @@ -1258,6 +1280,7 @@ flowchart TD | [`timeout`](../packages/util/timeout) | `util` | [`invariants`](../packages/support/invariants) | | [`scope`](../packages/core/scope) | `core` | [`invariants`](../packages/support/invariants) | | [`llm-mock-server`](../packages/support/llm-mock-server) | `support` | [`invariants`](../packages/support/invariants) | +| [`cmdline`](../packages/boot/cmdline) | `boot` | [`invariants`](../packages/support/invariants) | | [`base`](../packages/bundle/base) | `bundle` | [`invariants`](../packages/support/invariants) | | [`client-modules`](../packages/client/modules) | `client` | [`invariants`](../packages/support/invariants) | | [`client-schema-form`](../packages/client/schema-form) | `client` | [`invariants`](../packages/support/invariants) | @@ -1298,7 +1321,6 @@ flowchart TD | [`client-locale`](../packages/client/locale) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-models`](../packages/client/ui-models) | `client` | [`client-connection`](../packages/client/connection), [`client-runtime`](../packages/client/runtime), [`client-schema-form`](../packages/client/schema-form), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`invariants`](../packages/support/invariants) | | [`client-ui-settings`](../packages/client/ui-settings) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | -| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`invariants`](../packages/support/invariants) | | [`credentials-local`](../packages/credentials/credentials-local) | `credentials` | [`atomic-write`](../packages/util/atomic-write), [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths) | | [`settings-local`](../packages/settings/settings-local) | `settings` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`settings`](../packages/settings/settings) | | [`llm-deepseek`](../packages/llm/llm-deepseek) | `llm` | [`credentials`](../packages/credentials/credentials), [`environment`](../packages/util/environment), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`settings`](../packages/settings/settings), [`timeout`](../packages/util/timeout) | @@ -1326,7 +1348,6 @@ flowchart TD | [`code-runtime-worker`](../packages/code-runtime/code-runtime-worker) | `code-runtime` | [`code-runtime`](../packages/code-runtime/code-runtime), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | | [`host-directory-picker-browse`](../packages/host/directory-picker-browse) | `host` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | | [`host-directory-picker-native`](../packages/host/directory-picker-native) | `host` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-ui-workspace`](../packages/client/ui-workspace), [`invariants`](../packages/support/invariants) | -| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings) | | [`persona`](../packages/preset/persona) | `preset` | [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`sandbox`](../packages/sandbox/sandbox) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`session-persistence`](../packages/session/session-persistence) | `session` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`timeout`](../packages/util/timeout) | @@ -1340,11 +1361,12 @@ flowchart TD | [`spill-local`](../packages/spill/spill-local) | `spill` | [`invariants`](../packages/support/invariants), [`spill`](../packages/spill/spill) | | [`loader-smoke`](../packages/support/loader-smoke) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | | [`time-context`](../packages/context/time-context) | `context` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | -| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | +| [`message-feedback`](../packages/feedback/message-feedback) | `feedback` | [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`storage-domain`](../packages/storage/storage-domain), [`type-meta`](../packages/typert/type-meta) | | [`host-directory-picker-auto`](../packages/host/directory-picker-auto) | `host` | [`host-directory-picker-browse`](../packages/host/directory-picker-browse), [`host-directory-picker-native`](../packages/host/directory-picker-native), [`host-webserver`](../packages/host/webserver), [`invariants`](../packages/support/invariants) | | [`commands`](../packages/interaction/commands) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`session`](../packages/core/session) | | [`user-approval`](../packages/interaction/user-approval) | `interaction` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | | [`user-interaction`](../packages/interaction/user-interaction) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm) | +| [`agent-presets`](../packages/preset/agent-presets) | `preset` | [`agent`](../packages/core/agent), [`atomic-write`](../packages/util/atomic-write), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`settings`](../packages/settings/settings), [`system-prompt`](../packages/core/system-prompt) | | [`pty`](../packages/pty/pty) | `pty` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants) | | [`sandbox-local`](../packages/sandbox/sandbox-local) | `sandbox` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session) | | [`sandbox-policy`](../packages/sandbox/sandbox-policy) | `sandbox` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt) | @@ -1371,10 +1393,10 @@ flowchart TD | [`acp`](../packages/acp/acp) | `acp` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-approval`](../packages/interaction/user-approval) | | [`api-remotes`](../packages/api/remotes) | `api` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`typert-registry`](../packages/typert/registry) | | [`headless`](../packages/bundle/headless) | `bundle` | [`agent`](../packages/core/agent), [`agent-default-model`](../packages/core/agent-default-model), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | -| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | | [`tmux-context`](../packages/context/tmux-context) | `context` | [`agent`](../packages/core/agent), [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session) | | [`fs-e2b`](../packages/e2b/fs-e2b) | `e2b` | [`e2b`](../packages/e2b/e2b), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants) | -| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`user-id`](../packages/session/user-id) | +| [`command-feedback`](../packages/feedback/command-feedback) | `feedback` | [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-telemetry`](../packages/session/session-telemetry), [`user-id`](../packages/session/user-id) | +| [`host-apiproxy`](../packages/host/apiproxy) | `host` | [`agent-presets`](../packages/preset/agent-presets), [`invariants`](../packages/support/invariants) | | [`permission`](../packages/interaction/permission) | `interaction` | [`bash`](../packages/bash/bash), [`commands`](../packages/interaction/commands), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`settings`](../packages/settings/settings), [`user-approval`](../packages/interaction/user-approval) | | [`lsp-local`](../packages/lsp/lsp-local) | `lsp` | [`brand`](../packages/util/brand), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | | [`pty-local`](../packages/pty/pty-local) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`subprocess`](../packages/subprocess/subprocess) | @@ -1388,12 +1410,12 @@ flowchart TD | [`bash-sandbox`](../packages/bash/bash-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`bash-local`](../packages/bash/bash-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`pwsh-sandbox`](../packages/bash/pwsh-sandbox) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`pwsh-local`](../packages/bash/pwsh-local), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | | [`fs-sandbox`](../packages/fs/fs-sandbox) | `fs` | [`fs`](../packages/fs/fs), [`fs-local`](../packages/fs/fs-local), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy) | -| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | +| [`tool-fs`](../packages/fs/tool-fs) | `fs` | [`attachment`](../packages/attachment/attachment), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-fs-search`](../packages/fs/tool-fs-search) | `fs` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`subprocess`](../packages/subprocess/subprocess), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-str-replace-editor`](../packages/fs/tool-str-replace-editor) | `fs` | [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`tools`](../packages/core/tools) | | [`tool-skill`](../packages/skill/tool-skill) | `skill` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`skill`](../packages/skill/skill), [`tools`](../packages/core/tools) | | [`command-compact`](../packages/compact/command-compact) | `compact` | [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants) | -| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | +| [`subagent`](../packages/subagent/subagent) | `subagent` | [`agent`](../packages/core/agent), [`agent-presets`](../packages/preset/agent-presets), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`sandbox`](../packages/sandbox/sandbox), [`sandbox-policy`](../packages/sandbox/sandbox-policy), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-persistence`](../packages/session/session-persistence), [`session-projection`](../packages/session/session-projection), [`session-projection-cache`](../packages/session/session-projection-cache), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools), [`user-approval`](../packages/interaction/user-approval) | | [`tool-web`](../packages/web/tool-web) | `web` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`web`](../packages/web/web) | | [`spill-policy`](../packages/spill/spill-policy) | `spill` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`spill`](../packages/spill/spill), [`tools`](../packages/core/tools) | | [`tool-todo`](../packages/todo/tool-todo) | `todo` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`session`](../packages/core/session), [`session-projection`](../packages/session/session-projection), [`tools`](../packages/core/tools) | @@ -1403,13 +1425,15 @@ flowchart TD | [`tool-session-query`](../packages/session-query/tool-session-query) | `session-query` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`agent-loop-testkit`](../packages/support/agent-loop-testkit) | `support` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`llm-replay`](../packages/support/llm-replay) | `support` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session) | +| [`client-test-runtime`](../packages/client/test-runtime) | `client` | [`client-runtime`](../packages/client/runtime), [`client-ui-slots`](../packages/client/ui-slots), [`client-web-react`](../packages/client/web-react), [`host-apiproxy`](../packages/host/apiproxy), [`invariants`](../packages/support/invariants) | +| [`client-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`session-reference`](../packages/context/session-reference) | `context` | [`agent`](../packages/core/agent), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`retention`](../packages/util/retention), [`session`](../packages/core/session), [`session-query`](../packages/session-query/session-query) | | [`workspace-context`](../packages/context/workspace-context) | `context` | [`agent`](../packages/core/agent), [`fs`](../packages/fs/fs), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`paths`](../packages/util/paths), [`session`](../packages/core/session), [`tools`](../packages/core/tools) | | [`repeat-tool-guard`](../packages/guard/repeat-tool-guard) | `guard` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`timeout-policy`](../packages/guard/timeout-policy) | `guard` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-ask-user`](../packages/interaction/tool-ask-user) | `interaction` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools), [`user-interaction`](../packages/interaction/user-interaction) | | [`tool-lsp`](../packages/lsp/tool-lsp) | `lsp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`lsp`](../packages/lsp/lsp), [`system-prompt`](../packages/core/system-prompt), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | -| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`tools`](../packages/core/tools) | +| [`mcp-client`](../packages/mcp/mcp-client) | `mcp` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-bash-persistent`](../packages/pty/tool-bash-persistent) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`pty`](../packages/pty/pty), [`timeout`](../packages/util/timeout), [`tools`](../packages/core/tools) | | [`tool-pty`](../packages/pty/tool-pty) | `pty` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`pty`](../packages/pty/pty), [`retention`](../packages/util/retention), [`system-prompt`](../packages/core/system-prompt), [`tasks`](../packages/tasks/tasks), [`tools`](../packages/core/tools) | | [`tool-cordis`](../packages/self-modification/tool-cordis) | `self-modification` | [`invariants`](../packages/support/invariants), [`scope`](../packages/core/scope), [`tools`](../packages/core/tools) | @@ -1424,7 +1448,7 @@ flowchart TD | [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune) | `compact` | [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | | [`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), [`timeout`](../packages/util/timeout) | | [`subagent-claude-code`](../packages/subagent/subagent-claude-code) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`subprocess`](../packages/subprocess/subprocess), [`timeout`](../packages/util/timeout) | -| [`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/interaction/user-approval) | +| [`subagent-inprocess`](../packages/subagent/subagent-inprocess) | `subagent` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`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) | | [`tool-subagent-control`](../packages/subagent/tool-subagent-control) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | | [`tool-subagent-report`](../packages/subagent/tool-subagent-report) | `subagent` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools) | @@ -1432,7 +1456,6 @@ flowchart TD | [`web-app`](../packages/bundle/web-app) | `bundle` | [`bash-env`](../packages/bash/bash-env), [`invariants`](../packages/support/invariants), [`system-prompt`](../packages/core/system-prompt) | | [`client-ui-conversation`](../packages/client/ui-conversation) | `client` | [`agent`](../packages/core/agent), [`attachment`](../packages/attachment/attachment), [`brand`](../packages/util/brand), [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`invariants`](../packages/support/invariants), [`llm-retry`](../packages/llm/llm-retry), [`token-meter`](../packages/llm/token-meter), [`tools`](../packages/core/tools) | | [`sdk-protocol`](../packages/scaffold/protocol) | `scaffold` | [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | -| [`repository-plugin`](../packages/self-modification/repository-plugin) | `self-modification` | [`invariants`](../packages/support/invariants), [`mcp-client`](../packages/mcp/mcp-client), [`paths`](../packages/util/paths), [`skill-local`](../packages/skill/skill-local) | | [`tool-ralph`](../packages/workflow/tool-ralph) | `workflow` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`subagent`](../packages/subagent/subagent), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`workflow-workerthread`](../packages/workflow/workflow-workerthread) | `workflow` | [`agent`](../packages/core/agent), [`brand`](../packages/util/brand), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent), [`tools`](../packages/core/tools), [`workflow`](../packages/workflow/workflow) | | [`compact-basic`](../packages/compact/compact-basic) | `compact` | [`agent`](../packages/core/agent), [`commands`](../packages/interaction/commands), [`compact`](../packages/compact/compact), [`compact-tool-result-prune`](../packages/compact/compact-tool-result-prune), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`token-meter`](../packages/llm/token-meter) | @@ -1445,6 +1468,7 @@ flowchart TD | [`client-ui-goal`](../packages/client/ui-goal) | `client` | [`api-remotes`](../packages/api/remotes), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants) | | [`client-ui-plan`](../packages/client/ui-plan) | `client` | [`client-connection`](../packages/client/connection), [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants), [`plan-mode`](../packages/plan/plan-mode) | | [`client-ui-subagent`](../packages/client/ui-subagent) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`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), [`subagent`](../packages/subagent/subagent), [`token-meter`](../packages/llm/token-meter) | +| [`client-ui-task`](../packages/client/ui-task) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-tool`](../packages/client/ui-tool) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-primitives`](../packages/client/ui-primitives), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`agent-spine-demo`](../packages/examples/agent-spine-demo) | `examples` | [`agent`](../packages/core/agent), [`agent-loop`](../packages/core/agent-loop), [`bash-env`](../packages/bash/bash-env), [`goal`](../packages/goal/goal), [`goal-session`](../packages/goal/goal-session), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-retry`](../packages/llm/llm-retry), [`paths`](../packages/util/paths), [`scope`](../packages/core/scope), [`session`](../packages/core/session), [`session-title`](../packages/session/session-title), [`skill`](../packages/skill/skill), [`skill-local`](../packages/skill/skill-local), [`system-prompt`](../packages/core/system-prompt), [`tasks-local`](../packages/tasks/tasks-local), [`tool-bash`](../packages/bash/tool-bash), [`tool-goal`](../packages/goal/tool-goal), [`tool-skill`](../packages/skill/tool-skill), [`tool-tasks`](../packages/tasks/tool-tasks), [`tools`](../packages/core/tools), [`workspace-context`](../packages/context/workspace-context) | | [`jsonrpc`](../packages/scaffold/server) | `scaffold` | [`agent`](../packages/core/agent), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`llm-deepseek`](../packages/llm/llm-deepseek), [`scope`](../packages/core/scope), [`sdk-protocol`](../packages/scaffold/protocol), [`session`](../packages/core/session), [`subagent`](../packages/subagent/subagent) | diff --git a/docs/persistence-catalog.i18n.yaml b/docs/persistence-catalog.i18n.yaml index ee0b5cbdd6..fb2d7d3d3a 100644 --- a/docs/persistence-catalog.i18n.yaml +++ b/docs/persistence-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/persistence-catalog.md -persistence-catalog.md: f44569d3bacec0a832f4b4bca6acf4abb0846a0d -persistence-catalog.zh.md: 21ed29a3da2587a604ec90d201030fd644fc5bd4 +persistence-catalog.md: 88d8f833ce3e6c51692db74519279a5354a1759b +persistence-catalog.zh.md: 5ab0fa0c6ccb099ba10b9021625f20486a02d94c diff --git a/docs/persistence-catalog.md b/docs/persistence-catalog.md index f44569d3ba..88d8f833ce 100644 --- a/docs/persistence-catalog.md +++ b/docs/persistence-catalog.md @@ -7,7 +7,7 @@ Every event type that can appear in a session's durable event log: the complete This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md). -The envelope declarations below compose each event's `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction. +The envelope declarations below compose each event's `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction. ## Event envelope @@ -63,6 +63,17 @@ export type SessionEvent = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources @@ -79,7 +90,7 @@ export type SessionEvent = { }[T] ``` -Sources: [`packages/core/session/src/types.ts:316`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:323`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:352`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:384`](../packages/core/session/src/types.ts) +Sources: [`packages/core/session/src/types.ts:331`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:338`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:367`](../packages/core/session/src/types.ts) · [`packages/core/session/src/types.ts:399`](../packages/core/session/src/types.ts) ## Events @@ -192,7 +203,7 @@ Source: [`packages/interaction/user-approval/src/index.ts:67`](../packages/inter Types: [StreamChunk](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:261`](../packages/core/session/src/types.ts) #### `assistant/message` — surface @@ -208,7 +219,7 @@ Source: [`packages/core/session/src/types.ts:246`](../packages/core/session/src/ Types: [TokenUsage](subsystems/llm-streaming.md) -Source: [`packages/core/session/src/types.ts:253`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:268`](../packages/core/session/src/types.ts) ### `command/*` @@ -364,7 +375,7 @@ Source: [`packages/compact/compact/src/types.ts:33`](../packages/compact/compact 'feedback/record': { text: string } ``` -Source: [`packages/feedback/command-feedback/src/index.ts:25`](../packages/feedback/command-feedback/src/index.ts) +Source: [`packages/feedback/command-feedback/src/index.ts:62`](../packages/feedback/command-feedback/src/index.ts) ### `goal/*` @@ -488,7 +499,7 @@ Source: [`packages/plan/plan-mode/src/index.ts:53`](../packages/plan/plan-mode/s 'request/context': RequestContext ``` -Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:304`](../packages/core/session/src/types.ts) #### `request/header` — log-only @@ -500,7 +511,7 @@ Source: [`packages/core/session/src/types.ts:289`](../packages/core/session/src/ 'request/header': { header: EpochHeader; reason: RequestHeaderReason } ``` -Source: [`packages/core/session/src/types.ts:284`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:299`](../packages/core/session/src/types.ts) ### `sandbox/*` @@ -553,7 +564,7 @@ Source: [`packages/sandbox/sandbox-policy/src/session-mode.ts:33`](../packages/s 'session/end-seed': Record ``` -Source: [`packages/core/session/src/types.ts:312`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:327`](../packages/core/session/src/types.ts) #### `session/title` — log-only @@ -589,7 +600,7 @@ Source: [`packages/session/session-title-llm/src/index.ts:43`](../packages/sessi 'step/end': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:251`](../packages/core/session/src/types.ts) #### `step/start` — log-only @@ -598,7 +609,7 @@ Source: [`packages/core/session/src/types.ts:236`](../packages/core/session/src/ 'step/start': { turn: number; step: number } ``` -Source: [`packages/core/session/src/types.ts:234`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:249`](../packages/core/session/src/types.ts) ### `subagent/*` @@ -628,7 +639,7 @@ Source: [`packages/subagent/subagent/src/descriptor.ts:37`](../packages/subagent Types: [TodoItem](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:294`](../packages/core/session/src/types.ts) ### `tool/*` @@ -645,7 +656,7 @@ Source: [`packages/core/session/src/types.ts:279`](../packages/core/session/src/ Types: [CallId](subsystems/core.md) -Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:274`](../packages/core/session/src/types.ts) #### `tool/code-dispatch` — log-only @@ -714,7 +725,7 @@ Source: [`packages/core/tools/src/types.ts:40`](../packages/core/tools/src/types } ``` -Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:286`](../packages/core/session/src/types.ts) ### `turn/*` @@ -734,7 +745,7 @@ Source: [`packages/core/session/src/types.ts:271`](../packages/core/session/src/ Types: [TurnEndReason](subsystems/session.md) -Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:247`](../packages/core/session/src/types.ts) #### `turn/start` — log-only @@ -748,7 +759,7 @@ Source: [`packages/core/session/src/types.ts:232`](../packages/core/session/src/ 'turn/start': { turn: number } ``` -Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:238`](../packages/core/session/src/types.ts) ### `user/*` @@ -765,7 +776,7 @@ Source: [`packages/core/session/src/types.ts:223`](../packages/core/session/src/ 'user/message': UserMessage ``` -Source: [`packages/core/session/src/types.ts:244`](../packages/core/session/src/types.ts) +Source: [`packages/core/session/src/types.ts:259`](../packages/core/session/src/types.ts) ### `web/*` diff --git a/docs/persistence-catalog.zh.md b/docs/persistence-catalog.zh.md index 21ed29a3da..5ab0fa0c6c 100644 --- a/docs/persistence-catalog.zh.md +++ b/docs/persistence-catalog.zh.md @@ -9,7 +9,7 @@ 英文源文件根据源码生成(`scripts/gen-persistence-catalog.ts`),并由 `pnpm run verify-persistence-catalog`(`doc-sync`(文档同步门禁)的一部分)验证新鲜度;本中文文件作为经评审对侧通过双语配对维护。声明块保留源码声明和嵌套属性的 JSDoc,只移除其所在接口/模块带来的缩进,并使用 `ts persistence-catalog` 围栏(doc-typecheck 会跳过这些围栏,因为声明引用了其所属模块中的类型)。payload 中的类型名称会链接到记录该类型的页面。参见 [persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md)。 -以下信封声明组合了每个事件的 `type`、单调递增的 `seq`、以 epoch 毫秒表示的 `time`、`data`,以及条件字段 `surfaceOp`/`sourceEventSeqs`。**surface** 表示 `SurfaceEventType` 成员:它会生成一条 LLM(大语言模型)消息,并声明该事件如何加入 surface 列表。**log-only** 表示其他所有事件:这类记录可持久化、可回放,但不参与派生历史。每个 payload 均可进行 JSON 序列化(在 `Session.append` 处强制执行),整个格式固定为 `SESSION_FORMAT_VERSION = 0`:这是预发布格式,不暗示任何兼容性(参见[版本立场](subsystems/persistence.md))。范围仅限本仓库中的包;下游插件可以继续合并其他事件类型,而这些类型按设计不属于本目录。 +以下信封声明组合了每个事件的 `type`、单调递增的 `seq`、以 epoch 毫秒表示的 `time`、`data`、可选的未知类型跳过标记 `ignorable`,以及条件字段 `surfaceOp`/`sourceEventSeqs`。**surface** 表示 `SurfaceEventType` 成员:它会生成一条 LLM(大语言模型)消息,并声明该事件如何加入 surface 列表。**log-only** 表示其他所有事件:这类记录可持久化、可回放,但不参与派生历史。每个 payload 均可进行 JSON 序列化(在 `Session.append` 处强制执行),整个格式固定为 `SESSION_FORMAT_VERSION = 0`:这是预发布格式,不暗示任何兼容性(参见[版本立场](subsystems/persistence.md))。范围仅限本仓库中的包;下游插件可以继续合并其他事件类型,而这些类型按设计不属于本目录。 ## 事件信封 @@ -65,6 +65,17 @@ export type SessionEvent = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources diff --git a/docs/rescope.i18n.yaml b/docs/rescope.i18n.yaml new file mode 100644 index 0000000000..2959c354da --- /dev/null +++ b/docs/rescope.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/rescope.md +rescope.md: 3dde39875021e7a4161e1ae66550e9dedf5eb4fa +rescope.zh.md: a7f355cf651fb063bf2d4c3cefe18babd4a57401 diff --git a/docs/rescope.md b/docs/rescope.md new file mode 100644 index 0000000000..3dde398750 --- /dev/null +++ b/docs/rescope.md @@ -0,0 +1,53 @@ +# Vendored package rescope + +English | [中文](rescope.zh.md) + +The Cordis framework and its foundation libraries are vendored under [`vendor/`](../vendor/README.md) and published under the `@deepseek-ai` scope, because every harness package declares the framework as a peer dependency: publishing the harness publishes this layer with it, and under the upstream names that publication would squat them on the registry. This page is the name mapping; the decision and its consequences live in the [rescope Agent Note](../.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md), and the upstream commits in [`vendor/README.md`](../vendor/README.md). + +## Name mapping + +| Directory | Upstream name | Published name | Version | Role | +|---|---|---|---|---| +| `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.7 | Framework core: `Context`, `Service`, `Fiber`, events | +| `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | Shared utilities the framework and Schemastery build on | +| `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | Config schemas (`Schema`) behind every plugin's `Config` | +| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | `cordis.yml` loading, plugin resolution, repository cache | +| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | Config includes and patch overlays | +| `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | Nested plugin groups | +| `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | Disposal-aware timers on `ctx` | +| `vendor/hmr/` | `@cordisjs/plugin-hmr` | `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | Hot module replacement for plugins and config | +| `vendor/logger-console/` | `@cordisjs/plugin-logger-console` | `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | Console logger exporter | + +Subpath exports keep their path: `@cordisjs/plugin-loader/repository` becomes `@deepseek-ai/cordis-plugin-loader/repository`. + +## What the rename does not touch + +- **Directory names and versions.** `vendor/hmr/` stays `vendor/hmr/`, and every package keeps the upstream version its manifest table row records, so the vendored tree still reads as an upstream snapshot. +- **Dependency ranges.** A dependency entry changes its key, never its range: `"cordis": "^4.0.0-rc.7"` becomes `"@deepseek-ai/cordis": "^4.0.0-rc.7"`. `linkWorkspacePackages` resolves those preserved ranges to the pinned workspaces. +- **The Loader's `cordis:` builtin prefix.** `cordis:include` and `cordis:group` are a protocol prefix, not a package name. +- **The `cordis.yml` configuration family**, including `*.cordis.yml`, `*.cordis.snapshot.yml`, and `cordis.patch.yml`. +- **Harness packages whose own names contain the word**, such as `@deepseek-ai/dsh-tool-cordis`. +- **Upstream runtime identifiers**, such as Schemastery's `Symbol.for('schemastery')` and its `vendor:` metadata field. +- **Prose outside `docs/`.** `vendor/*/README.md`, package READMEs, and Agent Notes keep the names they were written with; a bare `cordis` there can also be the Python SDK's option name or an agent-preset id. Inside `docs/`, prose and every Markdown fence follow the rename. + +## What your code has to change + +| Site | Before | After | +|---|---|---| +| Module import | `import { Context } from 'cordis'` | `import { Context } from '@deepseek-ai/cordis'` | +| Typed-event merge | `declare module 'cordis'` | `declare module '@deepseek-ai/cordis'` | +| `package.json` dependency key | `"@cordisjs/plugin-hmr": "^1.0.15"` | `"@deepseek-ai/cordis-plugin-hmr": "^1.0.15"` | +| `cordis.yml` plugin entry | `name: '@cordisjs/plugin-include'` | `name: '@deepseek-ai/cordis-plugin-include'` | + +## Applying, verifying, and reverting + +[`scripts/rescope-vendor.ts`](../scripts/rescope-vendor.ts) owns the mapping above and performs the rename, so no reference is renamed by hand: + +```sh +pnpm run rescope-vendor # report what would change +pnpm run rescope-vendor --apply # rewrite every reference +pnpm run rescope-vendor:check # assert the post-state; runs in the hygiene gate +pnpm run rescope-vendor --apply --reverse # return to the upstream names +``` + +Re-apply it after an upstream sync ([procedure](../vendor/README.md)), and follow it with the regeneration it prints: `pnpm install` for the lockfile, `pnpm run gen-third-party-notices`, and `pnpm run verify-translation-pairing --write` for the bilingual pairs it touched. diff --git a/docs/rescope.zh.md b/docs/rescope.zh.md new file mode 100644 index 0000000000..a7f355cf65 --- /dev/null +++ b/docs/rescope.zh.md @@ -0,0 +1,53 @@ +# Vendored 包改名 + +[English](rescope.md) | 中文 + +Cordis 框架及其基础库以源码形式 vendored 在 [`vendor/`](../vendor/README.md) 下,并以 `@deepseek-ai` scope 发布:每个 harness 包都把框架声明为 peer dependency,发布 harness 就会连带发布这一层,用上游名发布等于在 registry 上占用别人的名字。本页是名字映射表;决策与影响见 [改名 Agent Note](../.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md),上游 commit 见 [`vendor/README.md`](../vendor/README.md)。 + +## 名字映射 + +| 目录 | 上游名 | 发布名 | 版本 | 角色 | +|---|---|---|---|---| +| `vendor/cordis/` | `cordis` | `@deepseek-ai/cordis` | 4.0.0-rc.7 | 框架核心:`Context`、`Service`、`Fiber`、事件 | +| `vendor/cosmokit/` | `cosmokit` | `@deepseek-ai/cosmokit` | 1.8.1 | 框架与 Schemastery 共用的基础工具 | +| `vendor/schemastery/` | `schemastery` | `@deepseek-ai/schemastery` | 3.18.0 | 配置 schema(`Schema`),每个插件的 `Config` 都基于它 | +| `vendor/loader/` | `@cordisjs/plugin-loader` | `@deepseek-ai/cordis-plugin-loader` | 1.0.0-rc.5 | `cordis.yml` 装载、插件解析、repository 缓存 | +| `vendor/include/` | `@cordisjs/plugin-include` | `@deepseek-ai/cordis-plugin-include` | 1.0.4 | 配置包含与 patch 叠加 | +| `vendor/group/` | `@cordisjs/plugin-group` | `@deepseek-ai/cordis-plugin-group` | 1.0.0 | 嵌套插件分组 | +| `vendor/timer/` | `@cordisjs/plugin-timer` | `@deepseek-ai/cordis-plugin-timer` | 1.1.2 | `ctx` 上随 disposal 回收的定时器 | +| `vendor/hmr/` | `@cordisjs/plugin-hmr` | `@deepseek-ai/cordis-plugin-hmr` | 1.0.15 | 插件与配置的热替换 | +| `vendor/logger-console/` | `@cordisjs/plugin-logger-console` | `@deepseek-ai/cordis-plugin-logger-console` | 1.0.0 | 控制台日志导出 | + +子路径导出保持原路径:`@cordisjs/plugin-loader/repository` 变成 `@deepseek-ai/cordis-plugin-loader/repository`。 + +## 改名不碰什么 + +- **目录名与版本号。** `vendor/hmr/` 仍是 `vendor/hmr/`,每个包保留清单表那行记录的上游版本,所以 vendored 树依旧读作一份上游快照。 +- **依赖 range。** 依赖条目只换键、不换范围:`"cordis": "^4.0.0-rc.7"` 变成 `"@deepseek-ai/cordis": "^4.0.0-rc.7"`;`linkWorkspacePackages` 靠这些保留下来的范围把它们解析到固定的 workspace。 +- **Loader 的 `cordis:` 内建前缀。** `cordis:include`、`cordis:group` 是协议前缀,不是包名。 +- **`cordis.yml` 配置文件家族**,包括 `*.cordis.yml`、`*.cordis.snapshot.yml`、`cordis.patch.yml`。 +- **名字里带这个词的 harness 包**,例如 `@deepseek-ai/dsh-tool-cordis`。 +- **上游运行时标识符**,例如 Schemastery 的 `Symbol.for('schemastery')` 及其 `vendor:` 元数据字段。 +- **`docs/` 之外的散文。** `vendor/*/README.md`、各包 README 与 Agent Note 保留写作当时的名字;那里的裸 `cordis` 也可能是 Python SDK 的选项名或某个 agent-preset 的 id。`docs/` 之内,散文与所有 Markdown 围栏都跟着改。 + +## 你的代码要改什么 + +| 位置 | 改前 | 改后 | +|---|---|---| +| 模块 import | `import { Context } from 'cordis'` | `import { Context } from '@deepseek-ai/cordis'` | +| 类型事件声明合并 | `declare module 'cordis'` | `declare module '@deepseek-ai/cordis'` | +| `package.json` 依赖键 | `"@cordisjs/plugin-hmr": "^1.0.15"` | `"@deepseek-ai/cordis-plugin-hmr": "^1.0.15"` | +| `cordis.yml` 插件条目 | `name: '@cordisjs/plugin-include'` | `name: '@deepseek-ai/cordis-plugin-include'` | + +## 施加、核验与回退 + +上面这份映射由 [`scripts/rescope-vendor.ts`](../scripts/rescope-vendor.ts) 承载并执行改名,任何引用都不靠手改: + +```sh +pnpm run rescope-vendor # report what would change +pnpm run rescope-vendor --apply # rewrite every reference +pnpm run rescope-vendor:check # assert the post-state; runs in the hygiene gate +pnpm run rescope-vendor --apply --reverse # return to the upstream names +``` + +上游 sync 之后重跑它([流程](../vendor/README.md)),并接上它打印的重生成:`pnpm install` 重生成 lockfile、`pnpm run gen-third-party-notices`、以及对它触及的双语对跑 `pnpm run verify-translation-pairing --write`。 diff --git a/docs/subsystems/README.i18n.yaml b/docs/subsystems/README.i18n.yaml index 9e578f7886..ebdccb923a 100644 --- a/docs/subsystems/README.i18n.yaml +++ b/docs/subsystems/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 docs/subsystems/README.md -README.md: fddbf460c8e9e7c6f9ed1d3375bdabe65947661f -README.zh.md: febc5a97426fef5ec4b2b80d9677957369994a3b +README.md: 560851eeda607b762456fe20c874b4497704709f +README.zh.md: 4acba4372e995b54a3bb326bec0cf9606f997773 diff --git a/docs/subsystems/README.md b/docs/subsystems/README.md index fddbf460c8..560851eeda 100644 --- a/docs/subsystems/README.md +++ b/docs/subsystems/README.md @@ -18,6 +18,7 @@ One page per subsystem of the DeepSeek Harness: what it is, the data structures | [settings.md](settings.md) | the user-settings seam: `SettingsNamespace` registration, layered resolution (defaults → composition `base` → user document), owner scopes, hot commits | | [credentials.md](credentials.md) | the credential seam: `CredentialRef` references (never values) in configuration, per-operation resolution, UI-safe `CredentialInfo`, provider source layers | | [session-query.md](session-query.md) | logical records, bounded exact-event reads, relationship traces, semantic filters/documents, and full-text result pages | +| [feedback.md](feedback.md) | lifecycle-bound per-message feedback records, optimistic versions, sidecar persistence, and the Host Remote contract | | [session-title.md](session-title.md) | durable title snapshots, cited source-message seqs, and the asynchronous provider contract | | [session-reference.md](session-reference.md) | structured cross-session references: `SessionReferenceInput`/`Candidate`, prepared message contexts, the stable error taxonomy | | [system-prompt.md](system-prompt.md) | per-assembly context, tool-provider results, prompt sections, and cooperative assembly | diff --git a/docs/subsystems/README.zh.md b/docs/subsystems/README.zh.md index febc5a9742..4acba4372e 100644 --- a/docs/subsystems/README.zh.md +++ b/docs/subsystems/README.zh.md @@ -18,6 +18,7 @@ | [settings.md](settings.md) | 用户设置 seam:`SettingsNamespace` 注册、分层解析(默认值 → 组合 `base` → 用户文档)、owner scope、热提交 | | [credentials.md](credentials.md) | 凭据 seam:配置中的 `CredentialRef` 引用(绝不含值)、按操作解析、对 UI 安全的 `CredentialInfo`、provider 来源层 | | [session-query.md](session-query.md) | 逻辑记录、有界精确事件读取、关系追踪、语义筛选器/文档与全文检索结果页 | +| [feedback.md](feedback.md) | 绑定生命周期的逐消息反馈记录、乐观版本、伴随记录持久化与 Host Remote 契约 | | [session-title.md](session-title.md) | 持久标题快照、被引用的来源消息 seq 与异步提供方约定 | | [session-reference.md](session-reference.md) | 结构化跨会话引用:`SessionReferenceInput`/`Candidate`、prepared 消息上下文、稳定错误分类 | | [system-prompt.md](system-prompt.md) | 逐次组装的上下文、工具提供方结果、提示词段落与协作式组装 | diff --git a/docs/subsystems/core.i18n.yaml b/docs/subsystems/core.i18n.yaml index a7d26cee1b..0123946525 100644 --- a/docs/subsystems/core.i18n.yaml +++ b/docs/subsystems/core.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/core.md -core.md: ad00c4da7d77b0e1ab4728173b202ebc17fb56a0 -core.zh.md: 9c606023c85369643e7148f829526b1f75ea3631 +core.md: 96655026f5affda6fed080496d975e2366f0356f +core.zh.md: e2cde8845ddf6b78f64d062fd8860c0c88b7ce11 diff --git a/docs/subsystems/core.md b/docs/subsystems/core.md index ad00c4da7d..96655026f5 100644 --- a/docs/subsystems/core.md +++ b/docs/subsystems/core.md @@ -546,7 +546,7 @@ async standingKeyFor(id?: string): Promise Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:80`](../../packages/preset/agent-presets/src/index.ts) diff --git a/docs/subsystems/core.zh.md b/docs/subsystems/core.zh.md index 9c606023c8..e2cde8845d 100644 --- a/docs/subsystems/core.zh.md +++ b/docs/subsystems/core.zh.md @@ -554,7 +554,7 @@ async standingKeyFor(id?: string): Promise Types: [ScopeKey](scope.md) -Source: [`packages/preset/agent-presets/src/index.ts:78`](../../packages/preset/agent-presets/src/index.ts) +Source: [`packages/preset/agent-presets/src/index.ts:80`](../../packages/preset/agent-presets/src/index.ts) diff --git a/docs/subsystems/feedback.i18n.yaml b/docs/subsystems/feedback.i18n.yaml new file mode 100644 index 0000000000..f6de2debe9 --- /dev/null +++ b/docs/subsystems/feedback.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/subsystems/feedback.md +feedback.md: 76a29f7d6ba604fa07ed56429c9b066e22639671 +feedback.zh.md: 5a409832de68b6d0bc9688a907c0f22edd3b0a43 diff --git a/docs/subsystems/feedback.md b/docs/subsystems/feedback.md new file mode 100644 index 0000000000..76a29f7d6b --- /dev/null +++ b/docs/subsystems/feedback.md @@ -0,0 +1,256 @@ +# Message Feedback + +English | [中文](feedback.zh.md) + +[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback) owns editable feedback for individual assistant messages. It is deliberately separate from the immutable Session-level `feedback/record` event: message feedback is a local storage-domain sidecar, not Session-log content or a projection, and it performs no telemetry handoff. + +Source: [`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) + +## Public types + +```ts type-equiv +/** Opaque compare-and-set token for one exact feedback item revision. */ +type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'> +``` + +```ts type-equiv +/** The human's overall judgment of one assistant message. */ +type MessageFeedbackRating = 'positive' | 'negative' +``` + +```ts type-equiv +/** One current feedback value and its opaque mutation token. */ +interface MessageFeedbackItem { + /** Stable identity of the assistant message inside the owning Session. */ + readonly messageId: MessageId + /** Overall positive or negative judgment. */ + readonly rating: MessageFeedbackRating + /** Optional explanation, preserved verbatim after validation. */ + readonly note?: string + /** Equality-only token replaced by every material create or update. */ + readonly version: MessageFeedbackVersion + /** Host-assigned creation time in Unix epoch milliseconds. */ + readonly createdAt: number + /** Host-assigned time of the most recent material update. */ + readonly updatedAt: number +} +``` + +```ts type-equiv +/** Read all message feedback belonging to one persisted Session lifecycle. */ +interface MessageFeedbackListRequest { + /** Persisted Session whose sidecar should be read. */ + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** Current feedback values for one Session, in first-creation order. */ +interface MessageFeedbackListValue { + /** Fresh immutable item snapshots. */ + readonly items: readonly MessageFeedbackItem[] +} +``` + +```ts type-equiv +/** Create or replace feedback for one assistant message. */ +interface MessageFeedbackPutRequest { + /** Persisted Session that owns the target message. */ + readonly sessionId: SessionId + /** Target assistant-message identity. */ + readonly messageId: MessageId + /** Desired overall judgment. */ + readonly rating: MessageFeedbackRating + /** Optional non-blank explanation. */ + readonly note?: string + /** Observed item version, or `null` to require that no item exists. */ + readonly ifVersion: MessageFeedbackVersion | null +} +``` + +```ts type-equiv +/** Delete feedback for one message after observing its current version. */ +interface MessageFeedbackDeleteRequest { + /** Persisted Session that owns the sidecar. */ + readonly sessionId: SessionId + /** Message whose feedback should be absent after this operation. */ + readonly messageId: MessageId + /** Observed item version; ignored when the item is already absent. */ + readonly ifVersion: MessageFeedbackVersion +} +``` + +```ts type-equiv +/** Idempotent deletion acknowledgement. */ +interface MessageFeedbackDeleteValue { + /** Stable postcondition shared by the first deletion and every retry. */ + readonly absent: true +} +``` + +```ts type-equiv +/** No persisted Session header exists for the requested id. */ +interface MessageFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** The id does not name a derived, append-origin assistant message. */ +interface MessageFeedbackTargetNotFound { + readonly code: 'target-not-found' + readonly sessionId: SessionId + readonly messageId: MessageId +} +``` + +```ts type-equiv +/** A material mutation did not match the addressed item's current version. */ +interface MessageFeedbackVersionConflict { + readonly code: 'version-conflict' + /** Authoritative current item, or `null` when it does not exist. */ + readonly current: MessageFeedbackItem | null +} +``` + +```ts type-equiv +/** A supplied note contains no non-whitespace character. */ +interface MessageFeedbackNoteBlank { + readonly code: 'note-blank' +} +``` + +```ts type-equiv +/** A supplied note exceeds the configured UTF-8 byte limit. */ +interface MessageFeedbackNoteTooLarge { + readonly code: 'note-too-large' + readonly maxBytes: number + readonly actualBytes: number +} +``` + +```ts type-equiv +/** Failures shared by the public message-feedback operations. */ +type MessageFeedbackFailure = + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge +``` + +```ts type-equiv +/** Successful public operation result. */ +interface MessageFeedbackSuccess { + readonly ok: true + readonly value: T +} +``` + +```ts type-equiv +/** Rejected public operation result with a stable business failure. */ +interface MessageFeedbackRejected { + readonly ok: false + readonly error: E +} +``` + +```ts type-equiv +/** Result returned by the message-feedback `list` operation. */ +type MessageFeedbackListResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +```ts type-equiv +/** Result returned by the message-feedback `put` operation. */ +type MessageFeedbackPutResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected< + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + > +``` + +```ts type-equiv +/** Result returned by the message-feedback `delete` operation. */ +type MessageFeedbackDeleteResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +## Data and concurrency + +One Session sidecar row contains its header identity `{createdAt, cwd}` and feedback items keyed by `MessageId`. Each item carries a positive or negative rating, an optional note, Host-assigned `createdAt`/`updatedAt` timestamps, and its own opaque version. Versions are compared only for equality and only against the addressed message; callers do not order or synthesize them. + +`put` uses strict optimistic concurrency: every request for an existing item must match its current `ifVersion`, including a no-op. A conflict returns the authoritative current item (or `null`), so a caller can reconcile a lost response or a concurrent edit without another read. Deleting an already absent item succeeds. A per-Session queue encloses inspection, read, conflict evaluation, and whole-row write, so these guarantees cover concurrent calls in one Host process. + +## Target and lifecycle authority + +`SessionPersistence.inspect()` supplies the target Session observation without publishing or resuming an Agent and without committing cold repair. A cold `listSnapshots()` preflight classifies definite absence; inspection failure for a catalogued Session propagates as infrastructure failure. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin, usage-only empty, and non-assistant records are not feedback targets. + +The stored `{createdAt, cwd}` identity must match the inspected header. A mismatch is treated as absence: `list` returns no items, while `put` may replace the stale row with one bound to the current header identity. Forks use a new Session identity and receive no sidecar copy even when their seed contains the same messages. + +## Persistence and Remote contract + +The service stores whole Session rows in the `message_feedback` storage domain through `ctx.storageDomain`. Before `put` commits a row that references a target message, a matching live target passes through the canonical `ctx.sessions.flush` checkpoint; both live and cold paths are then physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation is revalidated before the sidecar write, so the durable target log always precedes its sidecar commit. `maxNoteBytes` is required and bounds note text by UTF-8 bytes; the Web Host composition sets `8192`. The package publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract through `GatewayService` and `@Remote`; the generated Cordis surface below is the method-level authority. + +Plugin disposal closes mutation admission, drains accepted per-Session queue work, and then closes the storage domain. + +## Boundaries and limitations + +- The client Remote aggregate mount and UI consumer are separately owned and deferred. +- The mutation queue is process-local. Storage-domain has no cross-process conditional write, so multiple Host writers to one storage root have no compare-and-swap or lost-update guarantee. +- Session persistence has no durable deletion surface. The service does not treat `session/disposed` or `host/session-removed` as deletion and therefore performs no fake cascade; orphan sidecar rows may remain after out-of-band log removal. +- A request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. +- Cold requests scan the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. One Session row also has no item-count or aggregate-byte cap; `maxNoteBytes` bounds only each note until a concrete consumer owns a row policy. +- Header identity detects a reused id only when `{createdAt, cwd}` differs; a cloned log retaining the same header identity is indistinguishable by this contract. +- The Host contract records no authenticated actor or audit identity and therefore assumes a trusted caller boundary. + + + + + +## Cordis surface + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.messageFeedback` — `MessageFeedbackService` + +Storage-domain sidecar service. It inspects persisted Session history and never creates or resumes an Agent or Session. + +```ts cordis-catalog +/** + * Read feedback belonging to the current persisted Session lifecycle. + * A stale row from a reused Session id is invisible. + * @param request - Session identity to inspect and list. + * @returns current immutable items or `session-not-found`. + */ +@Remote('list') async list(request: MessageFeedbackListRequest): Promise + +/** + * Create or replace feedback for one derived append-origin assistant + * message. Every request must match the addressed item's current version; + * a matching no-op returns the stored item without changing its revision. + * @param request - target, desired value, and observed item version. + * @returns the committed item or an explicit business failure. + */ +@Remote('put') put(request: MessageFeedbackPutRequest): Promise + +/** + * Delete one feedback item. Absence is successful regardless of the + * supplied version; an existing item requires an exact version match. + * @param request - Session, message, and observed item version. + * @returns the stable absent postcondition, or an explicit failure. + */ +@Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise +``` + +Source: [`packages/feedback/message-feedback/src/index.ts:150`](../../packages/feedback/message-feedback/src/index.ts) + diff --git a/docs/subsystems/feedback.zh.md b/docs/subsystems/feedback.zh.md new file mode 100644 index 0000000000..5a409832de --- /dev/null +++ b/docs/subsystems/feedback.zh.md @@ -0,0 +1,256 @@ +# 消息反馈 + +[English](feedback.md) | 中文 + +[`@deepseek-ai/dsh-message-feedback`](../../packages/feedback/message-feedback)拥有针对单条 assistant 消息的可编辑反馈。它刻意与不可变的 Session 级 `feedback/record` 事件分离:message feedback 是本地 storage-domain 伴随记录(sidecar),不是 Session 日志内容或投影,也不执行遥测交接。 + +来源:[`packages/feedback/message-feedback/src/types.ts`](../../packages/feedback/message-feedback/src/types.ts) + +## 公开类型 + +```ts type-equiv +/** Opaque compare-and-set token for one exact feedback item revision. */ +type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'> +``` + +```ts type-equiv +/** The human's overall judgment of one assistant message. */ +type MessageFeedbackRating = 'positive' | 'negative' +``` + +```ts type-equiv +/** One current feedback value and its opaque mutation token. */ +interface MessageFeedbackItem { + /** Stable identity of the assistant message inside the owning Session. */ + readonly messageId: MessageId + /** Overall positive or negative judgment. */ + readonly rating: MessageFeedbackRating + /** Optional explanation, preserved verbatim after validation. */ + readonly note?: string + /** Equality-only token replaced by every material create or update. */ + readonly version: MessageFeedbackVersion + /** Host-assigned creation time in Unix epoch milliseconds. */ + readonly createdAt: number + /** Host-assigned time of the most recent material update. */ + readonly updatedAt: number +} +``` + +```ts type-equiv +/** Read all message feedback belonging to one persisted Session lifecycle. */ +interface MessageFeedbackListRequest { + /** Persisted Session whose sidecar should be read. */ + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** Current feedback values for one Session, in first-creation order. */ +interface MessageFeedbackListValue { + /** Fresh immutable item snapshots. */ + readonly items: readonly MessageFeedbackItem[] +} +``` + +```ts type-equiv +/** Create or replace feedback for one assistant message. */ +interface MessageFeedbackPutRequest { + /** Persisted Session that owns the target message. */ + readonly sessionId: SessionId + /** Target assistant-message identity. */ + readonly messageId: MessageId + /** Desired overall judgment. */ + readonly rating: MessageFeedbackRating + /** Optional non-blank explanation. */ + readonly note?: string + /** Observed item version, or `null` to require that no item exists. */ + readonly ifVersion: MessageFeedbackVersion | null +} +``` + +```ts type-equiv +/** Delete feedback for one message after observing its current version. */ +interface MessageFeedbackDeleteRequest { + /** Persisted Session that owns the sidecar. */ + readonly sessionId: SessionId + /** Message whose feedback should be absent after this operation. */ + readonly messageId: MessageId + /** Observed item version; ignored when the item is already absent. */ + readonly ifVersion: MessageFeedbackVersion +} +``` + +```ts type-equiv +/** Idempotent deletion acknowledgement. */ +interface MessageFeedbackDeleteValue { + /** Stable postcondition shared by the first deletion and every retry. */ + readonly absent: true +} +``` + +```ts type-equiv +/** No persisted Session header exists for the requested id. */ +interface MessageFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} +``` + +```ts type-equiv +/** The id does not name a derived, append-origin assistant message. */ +interface MessageFeedbackTargetNotFound { + readonly code: 'target-not-found' + readonly sessionId: SessionId + readonly messageId: MessageId +} +``` + +```ts type-equiv +/** A material mutation did not match the addressed item's current version. */ +interface MessageFeedbackVersionConflict { + readonly code: 'version-conflict' + /** Authoritative current item, or `null` when it does not exist. */ + readonly current: MessageFeedbackItem | null +} +``` + +```ts type-equiv +/** A supplied note contains no non-whitespace character. */ +interface MessageFeedbackNoteBlank { + readonly code: 'note-blank' +} +``` + +```ts type-equiv +/** A supplied note exceeds the configured UTF-8 byte limit. */ +interface MessageFeedbackNoteTooLarge { + readonly code: 'note-too-large' + readonly maxBytes: number + readonly actualBytes: number +} +``` + +```ts type-equiv +/** Failures shared by the public message-feedback operations. */ +type MessageFeedbackFailure = + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge +``` + +```ts type-equiv +/** Successful public operation result. */ +interface MessageFeedbackSuccess { + readonly ok: true + readonly value: T +} +``` + +```ts type-equiv +/** Rejected public operation result with a stable business failure. */ +interface MessageFeedbackRejected { + readonly ok: false + readonly error: E +} +``` + +```ts type-equiv +/** Result returned by the message-feedback `list` operation. */ +type MessageFeedbackListResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +```ts type-equiv +/** Result returned by the message-feedback `put` operation. */ +type MessageFeedbackPutResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected< + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + > +``` + +```ts type-equiv +/** Result returned by the message-feedback `delete` operation. */ +type MessageFeedbackDeleteResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected +``` + +## 数据与并发 + +每个 Session 的一条伴随记录包含 header 身份 `{createdAt, cwd}` 和以 `MessageId` 为键的反馈条目。每个条目携带好评或差评、可选备注、Host 分配的 `createdAt`/`updatedAt` 时间戳及自己的 opaque version。version 只能用于相等比较,且只与目标消息比较;调用方不能排序或自行合成它。 + +`put` 采用严格乐观并发:已有条目的每次请求都必须匹配当前 `ifVersion`,即使请求不会改变目标值。冲突会返回权威当前条目(不存在时为 `null`),因此调用方无需额外读取,即可协调丢失响应或并发编辑。删除已经不存在的条目同样成功。按 Session 划分的队列覆盖检查、读取、冲突判断与整行写入,因此这些保证适用于单个 Host 进程中的并发调用。 + +## 目标与生命周期权威 + +`SessionPersistence.inspect()` 提供目标 Session 的观测,且不会发布或恢复 Agent,也不会提交 cold repair。cold 路径先由 `listSnapshots()` 预检明确不存在;已进入目录的 Session 若检查失败,会按基础设施故障原样传播。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin、仅承载 usage 的空记录和非 assistant 记录都不是反馈目标。 + +存储的 `{createdAt, cwd}` 身份必须与检查所得 header 匹配。不匹配按不存在处理:`list` 返回空条目,`put` 则可用绑定当前 header 身份的新记录替换陈旧行。fork 使用新的 Session 身份,即使种子包含相同消息,也不获得伴随记录副本。 + +## 持久化与 Remote 契约 + +服务通过 `ctx.storageDomain` 在 `message_feedback` 存储域中保存完整 Session 行。`put` 提交引用目标消息的伴随记录前,身份匹配的 live 目标先经过权威 `ctx.sessions.flush` checkpoint;随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。写入伴随记录前会再次校验所得观测,因此目标日志的持久提交始终先于其伴随记录。`maxNoteBytes` 为必填项,按 UTF-8 字节限制备注文本;Web Host 组合将其设为 `8192`。该包通过 `GatewayService` 与 `@Remote` 发布 Host `messageFeedback.list`、`messageFeedback.put` 和 `messageFeedback.delete` 一元 Remote 契约;下方生成的 Cordis surface 是方法级权威。 + +Plugin disposal 会先关闭变更接纳,排空已进入各 Session 队列的工作,然后才关闭 storage domain。 + +## 边界与限制 + +- 客户端 Remote 聚合挂载与 UI 消费方由各自边界负责并保持延后。 +- 变更队列仅在进程内生效。storage-domain 没有跨进程条件写,因此多个 Host 写入同一存储根目录时,不提供 compare-and-swap 或防止丢失更新的保证。 +- Session persistence 没有持久删除接口。服务不把 `session/disposed` 或 `host/session-removed` 当作删除,因此不伪造级联;在带外移除日志后,孤儿伴随记录可能继续存在。 +- 请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 +- 由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。单个 Session 行也没有条目数或聚合字节上限;在具体消费方拥有行策略之前,`maxNoteBytes` 只限制每条备注。 +- 只有 `{createdAt, cwd}` 不同时,header 身份才能识别复用的 id;本契约无法区分保留相同 header 身份的克隆日志。 +- Host 契约不记录已认证的 actor 或审计身份,因此假设调用方边界可信。 + + + + + +## Cordis surface + +Generated from source by `scripts/gen-cordis-catalog.ts` (verified fresh by `pnpm run verify-cordis-catalog` in doc-sync; regenerate with `pnpm run gen-cordis-catalog`) — this section is byte-identical in both language sides of the page. Signature blocks use a `ts cordis-catalog` fence and keep the original source JSDoc; dispatch modes are defined in the [primer](../cordis-primer.md#dispatch-modes), and the framework-inherited `ctx` surface lives in [cordis-api/inherited.md](../cordis-api/inherited.md). + + + +### `ctx.messageFeedback` — `MessageFeedbackService` + +Storage-domain sidecar service. It inspects persisted Session history and never creates or resumes an Agent or Session. + +```ts cordis-catalog +/** + * Read feedback belonging to the current persisted Session lifecycle. + * A stale row from a reused Session id is invisible. + * @param request - Session identity to inspect and list. + * @returns current immutable items or `session-not-found`. + */ +@Remote('list') async list(request: MessageFeedbackListRequest): Promise + +/** + * Create or replace feedback for one derived append-origin assistant + * message. Every request must match the addressed item's current version; + * a matching no-op returns the stored item without changing its revision. + * @param request - target, desired value, and observed item version. + * @returns the committed item or an explicit business failure. + */ +@Remote('put') put(request: MessageFeedbackPutRequest): Promise + +/** + * Delete one feedback item. Absence is successful regardless of the + * supplied version; an existing item requires an exact version match. + * @param request - Session, message, and observed item version. + * @returns the stable absent postcondition, or an explicit failure. + */ +@Remote('delete') delete(request: MessageFeedbackDeleteRequest): Promise +``` + +Source: [`packages/feedback/message-feedback/src/index.ts:150`](../../packages/feedback/message-feedback/src/index.ts) + diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index 783f8b8d66..73cf951086 100644 --- a/docs/subsystems/filesystem.i18n.yaml +++ b/docs/subsystems/filesystem.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/filesystem.md -filesystem.md: 00e28130db9f60e6ad5f8c582ca5531482cdeb32 -filesystem.zh.md: 5cf6be12f41b36c8149a941a4d251c4497ed4738 +filesystem.md: 01fe2d07f5497374019855ca46ced7e173d21445 +filesystem.zh.md: e68246d7a44b8812e132e02979a2c0cda6adb792 diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index 00e28130db..01fe2d07f5 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -52,7 +52,7 @@ type FsTargetKey = Branded<'FsTargetKey'> type FsVersion = Branded<'FsVersion'> ``` -`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets the tool reject directories/special files before reading, and `size` lets it choose `readText` vs `streamText` without probing by failure. A protocol consumer that needs a byte ceiling applies it while consuming `streamText`, so the filesystem seam needs no consumer-specific bounded-read primitive. +`stat` returns metadata (never content), or `undefined` when the target is absent. `type` lets consumers reject directories and special files before reading, and `size` lets text consumers choose `readText` vs `streamText` without probing by failure. A text consumer applies its own retention ceiling while consuming `streamText`. Raw-byte consumers use `readBytes(target, signal, maxBytes)`; its required complete-content cap makes a known or discovered overflow fail with `FS_TOO_LARGE` instead of truncating or buffering without a bound. ```ts type-equiv /** @@ -256,6 +256,7 @@ type FsErrorCode = | 'FS_NOT_DIRECTORY' | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' + | 'FS_TOO_LARGE' | 'FS_PERMISSION_DENIED' | 'FS_SANDBOX_DENIED' | 'FS_IO_ERROR' @@ -274,7 +275,7 @@ type FsErrorCode = ## The service and the plugin -`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls from unseen/absent/present state and records `FsObservation` values. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures. +`FileSystem` (`ctx.fs`, abstract) owns the provider primitives: `resolve`, `processPath`, `fileUrl`, `contains`, `stat`, `lstat`, `readText`, `streamText`, `readBytes`, `listDir`, `writeText`, and `editText`. `dsh-fs-policy` registers **no service** — it is a plugin that adds policy through the `fs/*` event gate: it decides the write/edit intent waterfalls from unseen/absent/present state and records `FsObservation` values. The executor is `dsh-tool-fs`: it reads/writes/edits through `ctx.fs`, dispatches the waterfalls, and emits the recording event. The generated [`ctx.fs` section](#ctxfs--filesystem-abstract-seam) below shows the exact signatures. @@ -373,6 +374,18 @@ abstract readText(target: FsTarget, signal?: AbortSignal): Promise */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> +/** + * Read the whole regular file as raw bytes with no decoding or binary + * rejection. The bound lives at this seam so a backend can never buffer an + * unbounded file: a target known or discovered to exceed `maxBytes` fails + * with `FS_TOO_LARGE` instead of returning a truncated result. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @param maxBytes - inclusive byte cap on the complete content. + * @returns the full raw content, at most `maxBytes` long. + */ +abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise + /** * List direct children of a directory in stable name order. Returns resolved * child targets plus cheap metadata only; never reads file contents. diff --git a/docs/subsystems/filesystem.zh.md b/docs/subsystems/filesystem.zh.md index 5cf6be12f4..e68246d7a4 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -52,7 +52,7 @@ type FsTargetKey = Branded<'FsTargetKey'> type FsVersion = Branded<'FsVersion'> ``` -`stat` 返回元数据(从不返回内容),目标不存在时返回 `undefined`。`type` 让工具在读取前拒绝目录或特殊文件;`size` 让工具无需通过失败探测即可选择 `readText` 还是 `streamText`。需要字节上限的协议消费方在消费 `streamText` 时执行该上限,因此文件系统 seam 无需消费方专用的有界读取原语。 +`stat` 返回元数据(从不返回内容),目标不存在时返回 `undefined`。`type` 让消费方在读取前拒绝目录和特殊文件;`size` 让文本消费方无需通过失败探测即可选择 `readText` 还是 `streamText`。文本消费方在消费 `streamText` 时执行自己的保留量上限。原始字节消费方调用 `readBytes(target, signal, maxBytes)`;其必填的完整内容上限会使已知或读取中发现的超限以 `FS_TOO_LARGE` 失败,不会截断结果或无界缓冲。 ```ts type-equiv /** @@ -256,6 +256,7 @@ type FsErrorCode = | 'FS_NOT_DIRECTORY' | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' + | 'FS_TOO_LARGE' | 'FS_PERMISSION_DENIED' | 'FS_SANDBOX_DENIED' | 'FS_IO_ERROR' @@ -274,7 +275,7 @@ type FsErrorCode = ## 服务与插件 -`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:根据未见/缺失/存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 +`FileSystem`(`ctx.fs`,abstract)拥有提供方原语:`resolve`、`processPath`、`fileUrl`、`contains`、`stat`、`lstat`、`readText`、`streamText`、`readBytes`、`listDir`、`writeText` 与 `editText`。`dsh-fs-policy` **不注册服务**——它是一个通过 `fs/*` 事件门禁添加策略的插件:根据未见/缺失/存在状态对写入与编辑意图 waterfall 作出决策,并记录 `FsObservation` 值。执行器是 `dsh-tool-fs`:它通过 `ctx.fs` 读取/写入/编辑,分发 waterfall,并 emit 记录事件。下方生成的 [`ctx.fs` 小节](#ctxfs--filesystem-abstract-seam) 展示确切的 `ctx.fs` 签名。 @@ -373,6 +374,18 @@ abstract readText(target: FsTarget, signal?: AbortSignal): Promise */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> +/** + * Read the whole regular file as raw bytes with no decoding or binary + * rejection. The bound lives at this seam so a backend can never buffer an + * unbounded file: a target known or discovered to exceed `maxBytes` fails + * with `FS_TOO_LARGE` instead of returning a truncated result. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @param maxBytes - inclusive byte cap on the complete content. + * @returns the full raw content, at most `maxBytes` long. + */ +abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise + /** * List direct children of a directory in stable name order. Returns resolved * child targets plus cheap metadata only; never reads file contents. diff --git a/docs/subsystems/llm-streaming.i18n.yaml b/docs/subsystems/llm-streaming.i18n.yaml index 052ec4fedd..e1afba5d64 100644 --- a/docs/subsystems/llm-streaming.i18n.yaml +++ b/docs/subsystems/llm-streaming.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/llm-streaming.md -llm-streaming.md: 8ae4e8b376b4c4221e6179eb719fcf162f3031e4 -llm-streaming.zh.md: 7d244ab882521a90217873fc3cdee12cd5232db8 +llm-streaming.md: 17f984166906914c49b2c330bdf57b3cecdbd013 +llm-streaming.zh.md: 6519710dad8a174418bcc97f1bc4b36296ab969e diff --git a/docs/subsystems/llm-streaming.md b/docs/subsystems/llm-streaming.md index 8ae4e8b376..17f9841669 100644 --- a/docs/subsystems/llm-streaming.md +++ b/docs/subsystems/llm-streaming.md @@ -234,7 +234,7 @@ interface AppIdentity { product: string /** Product version; sourced from package metadata, never hand-copied. */ version: string - /** Public home URL of the app, used as the `User-Agent` comment. */ + /** Repository home URL of the app, used as the `User-Agent` comment. */ url: string } ``` diff --git a/docs/subsystems/llm-streaming.zh.md b/docs/subsystems/llm-streaming.zh.md index 7d244ab882..6519710dad 100644 --- a/docs/subsystems/llm-streaming.zh.md +++ b/docs/subsystems/llm-streaming.zh.md @@ -238,7 +238,7 @@ interface AppIdentity { product: string /** Product version; sourced from package metadata, never hand-copied. */ version: string - /** Public home URL of the app, used as the `User-Agent` comment. */ + /** Repository home URL of the app, used as the `User-Agent` comment. */ url: string } ``` diff --git a/docs/subsystems/persistence.i18n.yaml b/docs/subsystems/persistence.i18n.yaml index 65925a1608..1c442fb1a2 100644 --- a/docs/subsystems/persistence.i18n.yaml +++ b/docs/subsystems/persistence.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/persistence.md -persistence.md: 0266d17393d07c258036f7054a02c4ab9d3c74a2 -persistence.zh.md: ced83440160ae91ae37025d8024068fb8148b0c6 +persistence.md: fd694161ed8ae4c364de5c22d8eb06f1b0a91aec +persistence.zh.md: b616b282204e946e18e90271d1eaeb2d4ed70fc3 diff --git a/docs/subsystems/persistence.md b/docs/subsystems/persistence.md index 0266d17393..fd694161ed 100644 --- a/docs/subsystems/persistence.md +++ b/docs/subsystems/persistence.md @@ -87,6 +87,10 @@ interface SessionHeader { } ``` +## Format refusal — logs a build cannot faithfully read + +A backend refuses a log it cannot faithfully interpret with `SessionFormatUnsupportedError`, distinct from `SessionPersistenceCorruptionError` because nothing is damaged. A header `version` ahead of `SESSION_FORMAT_VERSION` names the direction ("written by a newer harness — upgrade the harness to open it"); one behind it states that this build ships no upgrade path. After legacy-shape normalization, an event type outside this build's generated vocabulary (`KNOWN_SESSION_EVENT_TYPES`, emitted by `gen-persistence-catalog`) refuses the same way unless the event's envelope carries `ignorable: true` — silently skipping an unrecognized required event could change how the rest of the log must be read. The message appends the raw log path when the backend keeps one artifact per session, so the refused text stays reachable. The JSONL backend refuses a foreign version straight from the raw header line, before validating today's header shape or decoding any event row — a structurally different future format still reports the upgrade direction, never "corrupt"; SQLite gates whole-file structure through its own `SCHEMA_VERSION` pragma first. Design rationale and the deferred upgrader chain live in the [session-log-version-mechanism note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). + ## `CreateSessionOptions` — seeding and metadata Creating a `Session` through the store takes a `seed` (initial replay or fork history) and `meta` (the storage-level fields the store folds into a `SessionHeader`). The store fills in `version`/`id` and defaults `createdAt`; the caller may supply the validated absolute `cwd`, the `parentSession` lineage, the `seedLength` seed boundary, the optional coarse `origin`, the `delegationDepth`, the `agentPreset` the agent was composed from, and an existing `createdAt`. `origin: 'subagent'` lets product navigation hide duplicate child rows; it does not prove that a descriptor is valid or that the child can resume. @@ -118,6 +122,22 @@ interface CreateSessionOptions { Replay/fork is therefore `ctx.sessions.create(id, { seed: seedEvents })`; resuming a *persisted* session into a live agent is `ctx.agents.resume({ resumeSessionId })`. +## `SessionRawArtifact` — verbatim stored artifact text + +A backend's own artifact text for one session, byte-identical to what it durably wrote (decoded from its physical encoding). `readRaw` returns it without reconstructing from parsed events, so backend-specific serialization (chunk packing, key order, line breaks) survives; backends without a per-session artifact, such as SQLite, inherit the `undefined` default. + +```ts type-equiv +/** A backend's own raw artifact text for one session, verbatim. */ +interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} +``` + ## Preparation and restoration ownership `SessionStore.prepare()` accepts ordinary creation options or fresh persistence graphs transferred through `RestoredSessionOptions`. The restoration branch validates and freezes the transferred header and events in place, so callers must retain no mutable aliases. `SessionPreparation` then owns the exact unpublished Session until publication or rollback; disposal is synchronous and idempotent. Persistence inspection exposes only `SessionInspection`, an immutable logical view borrowed from the same prepared Session. @@ -237,6 +257,21 @@ Durable append-only session storage. Implementations preserve contiguous, lossle */ abstract locate(meta: SessionHeader): SessionLocation | undefined +/** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ +readRaw(_id: SessionId, signal?: AbortSignal): Promise + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a @@ -342,5 +377,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/persistence.zh.md b/docs/subsystems/persistence.zh.md index ced8344016..b616b28220 100644 --- a/docs/subsystems/persistence.zh.md +++ b/docs/subsystems/persistence.zh.md @@ -87,6 +87,10 @@ interface SessionHeader { } ``` +## 格式拒绝:本构建无法可靠读取的日志 + +后端用 `SessionFormatUnsupportedError` 拒绝无法可靠解读的日志,它与 `SessionPersistenceCorruptionError` 区分,因为数据没有损坏。header 的 `version` 比 `SESSION_FORMAT_VERSION` 新时,消息说明方向("由更新的 harness 写入,请升级 harness 后打开");比它旧时说明本构建没有升级路径。经过 legacy 形状归一化后,本构建生成词汇表(`KNOWN_SESSION_EVENT_TYPES`,由 `gen-persistence-catalog` 生成)之外的事件类型同样被拒绝,除非该事件的信封带 `ignorable: true`:静默跳过一个不认识的必需事件可能改变日志其余部分的解读方式。后端为每个会话保留独立文件时,消息附上原始日志路径,被拒绝的文本仍然可读。JSONL 后端直接从原始 header 行拒绝外来版本,先于当前 header 形状校验和任何事件行解码,因此结构完全不同的未来格式仍会报告升级方向,绝不会报"损坏";SQLite 则先由自己的 `SCHEMA_VERSION` pragma 把关整个文件的结构。设计理由与推迟建设的升级器链见 [session-log 版本机制 Agent Note](../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。 + ## `CreateSessionOptions`:seed 与元数据 通过 store 创建 `Session` 时会接收 `seed`(初始回放或 fork 历史)与 `meta`(store 折叠进 `SessionHeader` 的存储层字段)。store 填充 `version`/`id` 并为 `createdAt` 提供默认值;调用方可以提供已校验的绝对 `cwd`、`parentSession` 谱系、`seedLength` 种子边界、可选的粗粒度 `origin`、`delegationDepth`、该 agent 所依据组装的 `agentPreset` 以及已有的 `createdAt`。`origin: 'subagent'` 让产品导航能够隐藏重复的 child 行;它不证明描述符有效,也不证明 child 可以恢复。 @@ -118,6 +122,22 @@ interface CreateSessionOptions { 因此,回放/fork 的调用方式为 `ctx.sessions.create(id, { seed: seedEvents })`;将一个*持久化*会话恢复为活跃 agent 的调用方式为 `ctx.agents.resume({ resumeSessionId })`。 +## `SessionRawArtifact`——逐字存储工件文本 + +后端为单个会话自持的工件文本,与其持久化写入的字节逐字一致(按物理编码解码)。`readRaw` 返回它而不从解析后事件重建,因此后端特定的序列化(chunk 打包、键序、换行)得以保留;没有每会话工件的后端(如 SQLite)继承 `undefined` 默认。 + +```ts type-equiv +/** A backend's own raw artifact text for one session, verbatim. */ +interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} +``` + ## 准备与恢复所有权 `SessionStore.prepare()` 接收普通创建选项,或通过 `RestoredSessionOptions` 转移所有权的新鲜持久化对象图。恢复分支会直接验证并冻结转移来的 header 与事件,因此调用方不得保留可变别名。`SessionPreparation` 随后持有该精确的未发布 Session,直至发布或回滚;dispose 是同步且幂等的。持久化检查只暴露 `SessionInspection`,即从同一个已准备 Session 借用的不可变逻辑视图。 @@ -237,6 +257,21 @@ Durable append-only session storage. Implementations preserve contiguous, lossle */ abstract locate(meta: SessionHeader): SessionLocation | undefined +/** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ +readRaw(_id: SessionId, signal?: AbortSignal): Promise + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a @@ -342,5 +377,5 @@ abstract listSnapshots(signal?: AbortSignal): Promise diff --git a/docs/subsystems/sandbox.i18n.yaml b/docs/subsystems/sandbox.i18n.yaml index 32bedb3e94..3fdd226576 100644 --- a/docs/subsystems/sandbox.i18n.yaml +++ b/docs/subsystems/sandbox.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/sandbox.md -sandbox.md: 20e0f36a5edb211ea409208d4e5e4a9be2e91d46 -sandbox.zh.md: 5f5465af46aa88d72b4a39f728f18855156b24ba +sandbox.md: 0478e30ada949102193407ee536d4974c841e371 +sandbox.zh.md: d2a59d1703b22deb1a4206911f488b4e30059580 diff --git a/docs/subsystems/sandbox.md b/docs/subsystems/sandbox.md index 20e0f36a5e..0478e30ada 100644 --- a/docs/subsystems/sandbox.md +++ b/docs/subsystems/sandbox.md @@ -2,13 +2,13 @@ English | [中文](sandbox.zh.md) -The process-sandbox seam of [dsh-sandbox](../../packages/sandbox/sandbox) wraps a same-world subprocess argv in a file-effect policy without coupling consumers to a platform runner. [dsh-sandbox-local](../../packages/sandbox/sandbox-local) supplies the Linux bwrap/Landlock and macOS Seatbelt backends; [dsh-bash-sandbox](../../packages/bash/bash-sandbox) is the first consumer. Containers, microVMs, and remote execution are sibling implementations of whole capability seams, not providers of `ctx.sandbox`. +The process-sandbox seam of [dsh-sandbox](../../packages/sandbox/sandbox) wraps a same-world subprocess argv in a file-effect policy without coupling consumers to a platform runner. [dsh-sandbox-local](../../packages/sandbox/sandbox-local) supplies Linux bwrap/Landlock, macOS Seatbelt, and the Windows ACL restricted-token backend; [dsh-bash-sandbox](../../packages/bash/bash-sandbox) and [dsh-pwsh-sandbox](../../packages/bash/pwsh-sandbox) consume it. Containers, microVMs, and remote execution are sibling implementations of whole capability seams, not providers of `ctx.sandbox`. Source: [`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts) ## Modes and enforcement -`SandboxMode` governs filesystem effects only. `read-only` denies every write — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants nothing; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. +`SandboxMode` governs filesystem effects only. `read-only` asks the backend to deny writes — the POSIX runners additionally grant the `/dev/null` sink their shells require, while the Windows ACL runner grants no explicit writable root and reports partial enforcement for its ambient ACL gaps; `workspace-write` permits writes under the workspace root and the backend's promised temp area; `danger-full-access` bypasses confinement. Network and process visibility are outside this vocabulary. ```ts type-equiv /** @@ -27,7 +27,7 @@ Only the first two modes can be sent to a provider. A `danger-full-access` consu type ConfinedSandboxMode = Exclude ``` -Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. +Enforcement is a reported fact. `full` means the backend governs every file effect promised by the mode; `partial` means an active backend or older kernel ABI governs only a subset, so consumers that require the absolute promise must reject or surface that distinction. Older Landlock ABIs and the Windows ACL runner's Everyone/hard-link boundaries are current partial cases. ```ts type-equiv /** @@ -55,10 +55,10 @@ interface SandboxExecutionPolicy { workspaceRoot: string /** * Opaque identity of the calling session (the branded `dsh-session` - * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session private temp subdirectory — the write grant itself is - * per-workspace, derived from the workspace root); absent for agentless - * calls, which fall back to per-call backend state. + * SessionId). Backends key per-session state off it (e.g. windows-acl gives + * each live session/workspace pair a random private temp directory and SID, + * while the workspace SID and standing grant remain per-workspace); absent + * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId } diff --git a/docs/subsystems/sandbox.zh.md b/docs/subsystems/sandbox.zh.md index 5f5465af46..d2a59d1703 100644 --- a/docs/subsystems/sandbox.zh.md +++ b/docs/subsystems/sandbox.zh.md @@ -2,13 +2,13 @@ [English](sandbox.md) | 中文 -[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将与宿主共享文件系统和内核的子进程 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock 与 macOS Seatbelt 后端;[dsh-bash-sandbox](../../packages/bash/bash-sandbox) 是第一个消费方。容器、microVM 和远程执行是完整能力 seam 的同级实现,而非 `ctx.sandbox` 的提供方。 +[dsh-sandbox](../../packages/sandbox/sandbox) 的进程沙箱 seam 将与宿主共享文件系统和内核的子进程 argv 包装在文件效果策略中,而不将消费方耦合到特定平台运行器。[dsh-sandbox-local](../../packages/sandbox/sandbox-local) 提供 Linux bwrap/Landlock、macOS Seatbelt 与 Windows ACL 受限令牌后端;[dsh-bash-sandbox](../../packages/bash/bash-sandbox) 和 [dsh-pwsh-sandbox](../../packages/bash/pwsh-sandbox) 是其消费方。容器、microVM 和远程执行是完整能力 seam 的同级实现,而非 `ctx.sandbox` 的提供方。 源码:[`packages/sandbox/sandbox/src/index.ts`](../../packages/sandbox/sandbox/src/index.ts) ## 模式与强制执行 -`SandboxMode` 仅管控文件系统效果。`read-only` 拒绝所有写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何写入;`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 +`SandboxMode` 仅管控文件系统效果。`read-only` 要求后端拒绝写入——POSIX runner 还会授予其 shell 所需的 `/dev/null` 接收器,而 Windows ACL runner 不授予任何显式可写根目录,并因环境 ACL 缺口报告部分强制执行;`workspace-write` 允许在工作区根目录及后端承诺的临时区域下写入;`danger-full-access` 绕过隔离。网络与进程可见性不在此处的定义范围内。 ```ts type-equiv /** @@ -27,7 +27,7 @@ type SandboxMode = 'read-only' | 'workspace-write' | 'danger-full-access' type ConfinedSandboxMode = Exclude ``` -强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。 +强制执行完整性是后端报告的事实。`full` 表示后端管控了该模式承诺的所有文件效果;`partial` 表示活跃后端或较旧的内核 ABI 仅管控其中一个子集,因此要求绝对保证的消费方必须拒绝或向上暴露这一区别。当前的部分强制执行情形包括较旧的 Landlock ABI,以及 Windows ACL runner 的 Everyone 与硬链接边界。 ```ts type-equiv /** @@ -55,10 +55,10 @@ interface SandboxExecutionPolicy { workspaceRoot: string /** * Opaque identity of the calling session (the branded `dsh-session` - * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session private temp subdirectory — the write grant itself is - * per-workspace, derived from the workspace root); absent for agentless - * calls, which fall back to per-call backend state. + * SessionId). Backends key per-session state off it (e.g. windows-acl gives + * each live session/workspace pair a random private temp directory and SID, + * while the workspace SID and standing grant remain per-workspace); absent + * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId } diff --git a/docs/subsystems/session.i18n.yaml b/docs/subsystems/session.i18n.yaml index 7ba2ae289d..177e33013d 100644 --- a/docs/subsystems/session.i18n.yaml +++ b/docs/subsystems/session.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/session.md -session.md: 0b78e51ebf6e2ad5c312268ad4bfb4392b0486df -session.zh.md: d1e91f684a835e08406f524efe876baa1a6a72cb +session.md: 990b249cde9f02343f2c668aee5d7c000837df56 +session.zh.md: 39e8ff1e8831fd75c8929c93e263622bb5aa6ea4 diff --git a/docs/subsystems/session.md b/docs/subsystems/session.md index 0b78e51ebf..990b249cde 100644 --- a/docs/subsystems/session.md +++ b/docs/subsystems/session.md @@ -215,6 +215,17 @@ type SessionEvent = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources @@ -733,7 +744,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md) -Source: [`packages/core/session/src/index.ts:810`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:813`](../../packages/core/session/src/index.ts) @@ -762,7 +773,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts) @@ -785,7 +796,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:85`](../../packages/core/session/src/index.ts) @@ -810,7 +821,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:97`](../../packages/core/session/src/index.ts) @@ -832,5 +843,5 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:106`](../../packages/core/session/src/index.ts) diff --git a/docs/subsystems/session.zh.md b/docs/subsystems/session.zh.md index d1e91f684a..39e8ff1e88 100644 --- a/docs/subsystems/session.zh.md +++ b/docs/subsystems/session.zh.md @@ -217,6 +217,17 @@ type SessionEvent = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources @@ -737,7 +748,7 @@ fork(source: SessionForkSource, boundary?: number, childSessionId?: SessionId): Types: [CreateSessionOptions](persistence.md) · [PrepareSessionOptions](persistence.md) · [SessionId](core.md) -Source: [`packages/core/session/src/index.ts:810`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:813`](../../packages/core/session/src/index.ts) @@ -766,7 +777,7 @@ Creation announcement during session publication. A synchronous throw vetoes and Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:74`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:75`](../../packages/core/session/src/index.ts) @@ -789,7 +800,7 @@ Emitted once when an announced session leaves the store, including publication r Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:84`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:85`](../../packages/core/session/src/index.ts) @@ -814,7 +825,7 @@ Post-commit, fire-and-forget append feed. The listener snapshot resolves before Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:96`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:97`](../../packages/core/session/src/index.ts) @@ -836,5 +847,5 @@ Awaited parallel durability checkpoint: every listener runs and the caller await Types: [Scoped](scope.md) -Source: [`packages/core/session/src/index.ts:105`](../../packages/core/session/src/index.ts) +Source: [`packages/core/session/src/index.ts:106`](../../packages/core/session/src/index.ts) diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index ddbc9ec3db..a5767904b9 100644 --- a/docs/subsystems/subagent.i18n.yaml +++ b/docs/subsystems/subagent.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/subagent.md -subagent.md: 961a16f58cb936205290d23ce6b9d94cb94607d5 -subagent.zh.md: ac97d27d9824ca62701e11ae99264adbddafbbd8 +subagent.md: cf728d9f15fdaaf8199954e91b564167e8efe439 +subagent.zh.md: 1a8e2e5837b8ac88f7d7b7ca767fb7aa21391a9f diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 961a16f58c..cf728d9f15 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -293,7 +293,12 @@ The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}. */ interface SubagentResult { - /** The child's final assistant output (the last assistant message's content). */ + /** + * The child's final assistant output is the content of its last non-empty + * assistant message. Empty-content messages, including usage-only messages, + * are skipped. Without a non-empty message, the output is its accumulated + * assistant text stream, or `[]` when the child produced neither. + */ readonly output: ContentBlock[] /** * The structured result after a requested `outputSchema` was successfully @@ -385,7 +390,10 @@ Each provider is a named child-agent transport, and multiple providers may coexi /** * One registered transport for running child agents. Providers are trusted * same-process implementations; callers treat descriptors and returned values - * as borrowed immutable data. + * as borrowed immutable data. The service may call one provider concurrently + * for distinct children. Providers isolate operation-local mutable state; a + * shared capacity controller may delay an operation but must not couple its + * settlement or cleanup to a sibling. */ interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ @@ -406,7 +414,8 @@ interface SubagentProvider { * initial turn. Before fulfillment, the provider owns setup and cleans any * unpublished partial resources before rejecting. Ownership transfers on * fulfillment; subsequent turn or infrastructure failure settles through - * the returned run. + * the returned run. Distinct starts may overlap; cancellation, failure, + * result settlement, and disposal remain independent for each run. */ start(request: ResolvedSubagentStartRequest): Promise /** @@ -421,6 +430,8 @@ interface SubagentProvider { * continuation manager owns identity reservation, composition, Agent * creation, prompt delivery, cold resume, ownership, and disposal, so a * provider never sees the child's Agent, handle, turns, or teardown. + * Distinct preparations may overlap; each follows its own signal and returns + * data belonging only to `request.sessionId`. */ prepareContinuable?(request: ContinuableCreateRequest): Promise } @@ -614,7 +625,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md) -Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:170`](../../packages/subagent/subagent/src/index.ts) @@ -640,7 +651,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:162`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts) @@ -657,7 +668,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts) @@ -674,7 +685,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts) @@ -698,5 +709,5 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:156`](../../packages/subagent/subagent/src/index.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index ac97d27d98..1a8e2e5837 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -293,7 +293,12 @@ type SubagentDescendantListEntry = SubagentListEntry & { * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}. */ interface SubagentResult { - /** The child's final assistant output (the last assistant message's content). */ + /** + * The child's final assistant output is the content of its last non-empty + * assistant message. Empty-content messages, including usage-only messages, + * are skipped. Without a non-empty message, the output is its accumulated + * assistant text stream, or `[]` when the child produced neither. + */ readonly output: ContentBlock[] /** * The structured result after a requested `outputSchema` was successfully @@ -387,7 +392,10 @@ interface SubagentRun { /** * One registered transport for running child agents. Providers are trusted * same-process implementations; callers treat descriptors and returned values - * as borrowed immutable data. + * as borrowed immutable data. The service may call one provider concurrently + * for distinct children. Providers isolate operation-local mutable state; a + * shared capacity controller may delay an operation but must not couple its + * settlement or cleanup to a sibling. */ interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ @@ -408,7 +416,8 @@ interface SubagentProvider { * initial turn. Before fulfillment, the provider owns setup and cleans any * unpublished partial resources before rejecting. Ownership transfers on * fulfillment; subsequent turn or infrastructure failure settles through - * the returned run. + * the returned run. Distinct starts may overlap; cancellation, failure, + * result settlement, and disposal remain independent for each run. */ start(request: ResolvedSubagentStartRequest): Promise /** @@ -423,6 +432,8 @@ interface SubagentProvider { * continuation manager owns identity reservation, composition, Agent * creation, prompt delivery, cold resume, ownership, and disposal, so a * provider never sees the child's Agent, handle, turns, or teardown. + * Distinct preparations may overlap; each follows its own signal and returns + * data belonging only to `request.sessionId`. */ prepareContinuable?(request: ContinuableCreateRequest): Promise } @@ -616,7 +627,7 @@ async start(name: string, request: SubagentStartRequest): Promise Types: [Agent](core.md) · [ContentBlock](llm-streaming.md) · [MessageId](llm-streaming.md) · [SessionId](core.md) -Source: [`packages/subagent/subagent/src/index.ts:167`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:170`](../../packages/subagent/subagent/src/index.ts) @@ -642,7 +653,7 @@ A published child settled. Scope-filtered dispatch uses the same delegating pare Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:162`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:165`](../../packages/subagent/subagent/src/index.ts) @@ -659,7 +670,7 @@ A provider became resolvable in the registry. 'subagent/provider-added'(provider: SubagentProvider): void ``` -Source: [`packages/subagent/subagent/src/index.ts:136`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:139`](../../packages/subagent/subagent/src/index.ts) @@ -676,7 +687,7 @@ A provider left the registry. Accepted runs remain holder-owned. 'subagent/provider-removed'(name: string): void ``` -Source: [`packages/subagent/subagent/src/index.ts:142`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:145`](../../packages/subagent/subagent/src/index.ts) @@ -700,5 +711,5 @@ A provider established a published child. For in-process providers, `ctx.agents. Types: [Scoped](scope.md) -Source: [`packages/subagent/subagent/src/index.ts:153`](../../packages/subagent/subagent/src/index.ts) +Source: [`packages/subagent/subagent/src/index.ts:156`](../../packages/subagent/subagent/src/index.ts) diff --git a/docs/subsystems/system-prompt.i18n.yaml b/docs/subsystems/system-prompt.i18n.yaml index c24ae31019..a63870cd80 100644 --- a/docs/subsystems/system-prompt.i18n.yaml +++ b/docs/subsystems/system-prompt.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/system-prompt.md -system-prompt.md: bdc0e994fb8e784a19814574c405d8cc3dce2d11 -system-prompt.zh.md: db6932b18f4721020fed567d49727f863eb06608 +system-prompt.md: 56617ef9d3d8da89673a4624abcef73e58d72cab +system-prompt.zh.md: cafea4f9689879b3fd8d0e1fff7249fcb02a7c12 diff --git a/docs/subsystems/system-prompt.md b/docs/subsystems/system-prompt.md index bdc0e994fb..56617ef9d3 100644 --- a/docs/subsystems/system-prompt.md +++ b/docs/subsystems/system-prompt.md @@ -39,7 +39,7 @@ interface ToolProviderResult { ## Prompt sections -`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. +`PromptSection` is a readonly same-process registration contract. Its text may be static or resolved from the current assembly context. One effective `complete` section becomes the sole prompt section after cooperative assembly. ```ts type-equiv /** One contributed section of the system prompt (registry input). */ @@ -58,6 +58,13 @@ interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) + /** + * Treat this contribution as the complete system prompt. Assembly still + * runs the cooperative waterfall so tools, contexts, and variables can be + * resolved, then restores this exact section as the sole prompt section. + * More than one effective complete section makes assembly fail. + */ + readonly complete?: boolean } ``` @@ -132,14 +139,16 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine /** * Assemble global and scoped providers, detach tool parameters, apply * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals; the returned waterfall value is authoritative. + * variables shadow globals. The returned waterfall value is authoritative + * except that an effective complete section is restored afterwards as the + * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. - * @returns the authoritative post-waterfall assembly. + * @returns the post-waterfall assembly with any complete prompt enforced. */ async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:334`](../../packages/core/system-prompt/src/index.ts) @@ -149,7 +158,7 @@ Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/sys #### `system-prompt/assemble` — waterfall -Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. +Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt. ```ts cordis-catalog /** @@ -157,7 +166,9 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners * receive only that scope's assemblies. The returned value is authoritative. * A supplied signal controls only this explicit assembly request and must not - * be retained to control later turns. + * be retained to control later turns. A registered complete section is + * restored after this waterfall, so listeners cannot add to or replace + * that scope's system prompt. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -167,7 +178,7 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc Types: [Scoped](scope.md) -Source: [`packages/core/system-prompt/src/index.ts:29`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:31`](../../packages/core/system-prompt/src/index.ts) @@ -184,5 +195,5 @@ Emitted when any prompt provider changes. This registry notification is unfilter 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:37`](../../packages/core/system-prompt/src/index.ts) diff --git a/docs/subsystems/system-prompt.zh.md b/docs/subsystems/system-prompt.zh.md index db6932b18f..cafea4f968 100644 --- a/docs/subsystems/system-prompt.zh.md +++ b/docs/subsystems/system-prompt.zh.md @@ -39,7 +39,7 @@ interface ToolProviderResult { ## 提示词段落 -`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。 +`PromptSection` 是一份只读的同进程注册约定。其文本可以是静态的,也可以从当前组装上下文动态解析。协作式组装完成后,一个有效的 `complete` 段会成为唯一的提示词段落。 ```ts type-equiv /** One contributed section of the system prompt (registry input). */ @@ -58,6 +58,13 @@ interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) + /** + * Treat this contribution as the complete system prompt. Assembly still + * runs the cooperative waterfall so tools, contexts, and variables can be + * resolved, then restores this exact section as the sole prompt section. + * More than one effective complete section makes assembly fail. + */ + readonly complete?: boolean } ``` @@ -132,14 +139,16 @@ variable(name: string, provider: (context: AssembleContext) => string | undefine /** * Assemble global and scoped providers, detach tool parameters, apply * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals; the returned waterfall value is authoritative. + * variables shadow globals. The returned waterfall value is authoritative + * except that an effective complete section is restored afterwards as the + * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. - * @returns the authoritative post-waterfall assembly. + * @returns the post-waterfall assembly with any complete prompt enforced. */ async assemble(context: AssembleContext = {}): Promise ``` -Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:334`](../../packages/core/system-prompt/src/index.ts) @@ -149,7 +158,7 @@ Source: [`packages/core/system-prompt/src/index.ts:325`](../../packages/core/sys #### `system-prompt/assemble` — waterfall -Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. +Expert waterfall over the assembled sections, contexts, tools, and variables. Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners receive only that scope's assemblies. The returned value is authoritative. A supplied signal controls only this explicit assembly request and must not be retained to control later turns. A registered complete section is restored after this waterfall, so listeners cannot add to or replace that scope's system prompt. ```ts cordis-catalog /** @@ -157,7 +166,9 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners * receive only that scope's assemblies. The returned value is authoritative. * A supplied signal controls only this explicit assembly request and must not - * be retained to control later turns. + * be retained to control later turns. A registered complete section is + * restored after this waterfall, so listeners cannot add to or replace + * that scope's system prompt. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -167,7 +178,7 @@ Expert waterfall over the assembled sections, contexts, tools, and variables. Sc Types: [Scoped](scope.md) -Source: [`packages/core/system-prompt/src/index.ts:29`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:31`](../../packages/core/system-prompt/src/index.ts) @@ -184,5 +195,5 @@ Emitted when any prompt provider changes. This registry notification is unfilter 'system-prompt/change'(): void ``` -Source: [`packages/core/system-prompt/src/index.ts:35`](../../packages/core/system-prompt/src/index.ts) +Source: [`packages/core/system-prompt/src/index.ts:37`](../../packages/core/system-prompt/src/index.ts) diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index 6a95ccf73e..75b47a572b 100644 --- a/docs/subsystems/tasks.i18n.yaml +++ b/docs/subsystems/tasks.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tasks.md -tasks.md: 2da8212b3edf7111180b60162108af0aebb249c1 -tasks.zh.md: 825a69029ae931f9668cca863c6ebb2324634a9a +tasks.md: 6d205d9a7840aef1c115c97836886f51bed829b9 +tasks.zh.md: 59b7c03d240c45e633f55583b3e08776ec470d52 diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index 2da8212b3e..6d205d9a78 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -246,6 +246,30 @@ abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSigna */ abstract onTaskDone(listener: TaskDoneListener): () => void +/** +/** + * Register an effect-scoped observer of visible-set changes. It fires after + * every commit that changes what {@link list} returns for that owner — + * registration, every stopping transition (including the one teardown + * performs before it awaits a slow producer), settlement, owner-disposal + * removal, and the emptying that service disposal commits — so an observer + * re-reads rather than accumulating deltas. + * + * Delivery is owner-relative on the same terms as {@link onTaskDone}: an + * observer registered from an unscoped context — a host composition's own + * carrier — sees every owner, while one registered under an agent + * composition's scope sees exactly the agents composed under it. + * + * This is not a superset of {@link onTaskDone}: that one delivers the terminal + * record under first-wins semantics a control surface couples to notice + * delivery, while this one carries no delivery meaning and marks nothing + * reported. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned task changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ +abstract onTasksChanged(listener: TasksChangedListener): () => void + /** * Attach an effect-scoped surface that can read and stop tasks. It serves the * owners its registering context's scope covers, and {@link start} refuses an @@ -258,5 +282,5 @@ abstract attachSurface(name: string): () => void Types: [Agent](core.md) -Source: [`packages/tasks/tasks/src/index.ts:55`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:58`](../../packages/tasks/tasks/src/index.ts) diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index 825a69029a..59b7c03d24 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -151,7 +151,7 @@ interface TaskRead { ## 服务行为 -抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 +抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) Service Definition 规定原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 与 `onTasksChanged` 监听器,以及 `attachSurface` 何时可用;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部 Service provider。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。Service Definition 约定见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的 Consumer 见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 @@ -246,6 +246,30 @@ abstract wait(id: TaskId, timeoutMs: number, caller?: Agent, signal?: AbortSigna */ abstract onTaskDone(listener: TaskDoneListener): () => void +/** +/** + * Register an effect-scoped observer of visible-set changes. It fires after + * every commit that changes what {@link list} returns for that owner — + * registration, every stopping transition (including the one teardown + * performs before it awaits a slow producer), settlement, owner-disposal + * removal, and the emptying that service disposal commits — so an observer + * re-reads rather than accumulating deltas. + * + * Delivery is owner-relative on the same terms as {@link onTaskDone}: an + * observer registered from an unscoped context — a host composition's own + * carrier — sees every owner, while one registered under an agent + * composition's scope sees exactly the agents composed under it. + * + * This is not a superset of {@link onTaskDone}: that one delivers the terminal + * record under first-wins semantics a control surface couples to notice + * delivery, while this one carries no delivery meaning and marks nothing + * reported. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned task changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ +abstract onTasksChanged(listener: TasksChangedListener): () => void + /** * Attach an effect-scoped surface that can read and stop tasks. It serves the * owners its registering context's scope covers, and {@link start} refuses an @@ -258,5 +282,5 @@ abstract attachSurface(name: string): () => void Types: [Agent](core.md) -Source: [`packages/tasks/tasks/src/index.ts:55`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:58`](../../packages/tasks/tasks/src/index.ts) diff --git a/docs/subsystems/telemetry.i18n.yaml b/docs/subsystems/telemetry.i18n.yaml index f5cda71a1d..09caaa6039 100644 --- a/docs/subsystems/telemetry.i18n.yaml +++ b/docs/subsystems/telemetry.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/telemetry.md -telemetry.md: 5ea5c67210ce1387cbd886935e914baf7f904fbb -telemetry.zh.md: bd8fc8acc4c8522d8b1e4bc543431c0abf224411 +telemetry.md: 97694a9a5a209224087d0d8454d83e29ce568ea4 +telemetry.zh.md: 9e8b17f4bddb3debdf4dff9d3c3fed1296ebf3d7 diff --git a/docs/subsystems/telemetry.md b/docs/subsystems/telemetry.md index 5ea5c67210..97694a9a5a 100644 --- a/docs/subsystems/telemetry.md +++ b/docs/subsystems/telemetry.md @@ -2,7 +2,7 @@ English | [中文](telemetry.zh.md) -Outbound session reporting is one [capability seam](../capability-seams.md): its Service Definition ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) declares the minimal backend contract, and its capture coordinator owns the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, and handoff cursor; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) uses the OpenTelemetry JS SDK's log pipeline with its configuration unchanged. This optional capability is not part of the agent loop, and nothing here reaches a model request. The harness stops after it calls `emit()`; the reporting SDK owns batching, retry, queueing, and loss policy. The [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md) records that rule and the rejected alternatives. The [Service Definition README](../../packages/session/session-telemetry/README.md) defines the capture-point, cursor, and projection contracts. +Outbound session reporting is split as a [capability seam](../capability-seams.md): the Service Definition and capture coordinator ([dsh-session-telemetry](../../packages/session/session-telemetry), `ctx.telemetry`) own the capture points, fixed chunk projection, `telemetry/record` redaction waterfall, handoff cursor, and minimal backend contract; the Service provider a deployment loads ([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel)) is the OpenTelemetry JS SDK's log pipeline configured verbatim. It is one optional capability, not part of the agent-loop spine, and nothing here reaches a model request. The boundary axiom — the harness's aspect ends at `emit()`; batching, retry, queueing, and loss policy belong to the reporting SDK — and the rejected alternatives are pinned in the [revival Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md); the capture points, cursor, and projection contracts live in the [Service Definition README](../../packages/session/session-telemetry/README.md). Source: [`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -56,6 +56,21 @@ interface TelemetryRecord { Only the first `assistant/chunk` of each `(turn, step)` ships — the stream-started signal; the rest drop at capture, so `seq` gaps are routine on the wire and never a loss signal. Every other [session event](session.md) type, including plugin-merged ones the seam never heard of, passes through whole. Delivery is best-effort: the cursor marks handed-off, not delivered, records can be lost (crash, reload window) and duplicated (cursor-less re-adoption, SDK retries), so receivers dedupe ledger records on `(session.id, event.seq)`; ops records deliberately omit that identity — they are signals to alert on, not entries to sum, and tolerate duplicates instead. +## The sharing disclosure + +The seam's acknowledgement contract (owned by the [Service Definition README's sharing-disclosure section](../../packages/session/session-telemetry/README.md#the-sharing-disclosure)): every backend discloses its deployment-selected sharing policy through the required abstract `sharing` member on `ctx.telemetry`, and consumers render "not configured" only when no telemetry service is mounted. The disclosure states the current policy, never delivery or retention — handoff is the non-blocking enqueue, and batching, retry, and loss policy stay the reporting SDK's. + +```ts type-equiv +/** + * Deployment-selected session-sharing policy disclosed by a mounted + * {@link Telemetry} backend to human-facing acknowledgement surfaces (the + * `/feedback` command's confirmation text). The seam owns the vocabulary so + * any backend can disclose a policy without depending on the OTel package; + * the values mirror the OTel backend's serialized `TelemetryMode` choices. + */ +type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' +``` + ## The backend contract ```ts type-equiv @@ -104,7 +119,7 @@ interface TelemetryBackend { } ``` -`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the loadable form of this contract: each context accepts one implementation and throws on a duplicate. A backend constructs `TelemetryCoordinator` in its constructor to install capture. +`Telemetry` (`ctx.telemetry`, [signatures](#ctxtelemetry--telemetry-abstract-seam)) is the contract's loadable form — one implementation per context, duplicate load throws — and a backend composes the seam's `TelemetryCoordinator` in its constructor to install the capture side. ## The redact waterfall: `telemetry/record` @@ -141,7 +156,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:148`](../../packages/session/session-telemetry/src/index.ts) diff --git a/docs/subsystems/telemetry.zh.md b/docs/subsystems/telemetry.zh.md index bd8fc8acc4..9e8b17f4bd 100644 --- a/docs/subsystems/telemetry.zh.md +++ b/docs/subsystems/telemetry.zh.md @@ -2,7 +2,7 @@ [English](telemetry.md) | 中文 -对外会话上报是一项[能力 seam](../capability-seams.md):其 Service Definition([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)声明最小后端约定,其捕获协调器负责捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)和 handoff 游标;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))按原配置使用 OpenTelemetry JS SDK 日志流水线。这项能力可选,不属于 agent loop(智能体循环),这里也没有任何内容会进入模型请求。Harness 调用 `emit()` 后停止处理;上报 SDK 负责批处理、重试、排队和丢失策略。[复活 Agent Note](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)记录了这条规则和被否决的替代方案。[Service Definition README](../../packages/session/session-telemetry/README.md) 定义捕获点、游标和投影约定。 +对外的会话上报拆分为一项[能力 seam](../capability-seams.md):Service Definition 与捕获协调器([dsh-session-telemetry](../../packages/session/session-telemetry),`ctx.telemetry`)拥有捕获点、固定分片投影、`telemetry/record` 脱敏 waterfall(瀑布式事件)、handoff 游标与最小后端约定;部署方加载的 Service provider([dsh-session-telemetry-otel](../../packages/session/session-telemetry-otel))则是原样配置的 OpenTelemetry JS SDK 日志流水线。它是一项可选能力,不属于 agent loop(智能体循环)主干,这里也没有任何内容会进入模型请求。边界公理(harness 的职责止于 `emit()`;批处理、重试、排队与丢失策略都属于上报 SDK)连同被否决的替代方案,均已在[复活 Agent Note(agent 决策记录)](../../.agents/notes/implemented/feature/2026-07-23-session-telemetry-otel-revival.md)中定案;捕获点、游标与投影的约定见 [Service Definition README](../../packages/session/session-telemetry/README.md)。 源码:[`packages/session/session-telemetry/src/index.ts`](../../packages/session/session-telemetry/src/index.ts) @@ -56,6 +56,21 @@ interface TelemetryRecord { 每个 `(turn, step)` 只发出第一条 `assistant/chunk`,即「流已开始」的信号;其余分片在捕获时丢弃,因此导出流中的 `seq` 缺口是常态,绝不是丢失信号。其他所有[会话事件](session.md)类型都会完整透传,包括该 seam 从未听说过、由插件合并进来的事件类型。投递是尽力而为的:游标标记的是「已交接」而非「已送达」,记录可能丢失(崩溃、重载窗口)也可能重复(无游标的重新接管、SDK 重试),因此接收端对 ledger 记录基于 `(session.id, event.seq)` 去重;ops 记录刻意省略这类标识——它们是用于告警的信号,而非用于累加的条目,重复被容忍而非被去重。 +## 共享披露 + +该 seam 的确认契约(归属 [Service Definition README 的共享披露段](../../packages/session/session-telemetry/README.md#the-sharing-disclosure)):每个后端都通过 `ctx.telemetry` 上必需的抽象 `sharing` 成员披露其部署级共享策略,消费方只有在未挂载任何遥测服务时才渲染「未配置」。披露只陈述当前策略,绝不承诺投递或留存——交接是非阻塞入队,批处理、重试与丢失策略仍归上报 SDK。 + +```ts type-equiv +/** + * Deployment-selected session-sharing policy disclosed by a mounted + * {@link Telemetry} backend to human-facing acknowledgement surfaces (the + * `/feedback` command's confirmation text). The seam owns the vocabulary so + * any backend can disclose a policy without depending on the OTel package; + * the values mirror the OTel backend's serialized `TelemetryMode` choices. + */ +type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' +``` + ## 后端约定 ```ts type-equiv @@ -104,7 +119,7 @@ interface TelemetryBackend { } ``` -`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载类型:每个上下文只允许一个实现,重复加载会抛出异常。后端在构造函数中创建 `TelemetryCoordinator`,以安装捕获处理。 +`Telemetry`(`ctx.telemetry`,[签名](#ctxtelemetry--telemetry-abstract-seam))是该约定的可加载形态:每个上下文只允许一个实现,重复加载会抛出异常;后端在其构造函数中组合 seam 的 `TelemetryCoordinator`,以此装配捕获侧。 ## 脱敏 waterfall:`telemetry/record` @@ -141,7 +156,7 @@ flush?(): void abstract shutdown(): Promise ``` -Source: [`packages/session/session-telemetry/src/index.ts:139`](../../packages/session/session-telemetry/src/index.ts) +Source: [`packages/session/session-telemetry/src/index.ts:148`](../../packages/session/session-telemetry/src/index.ts) diff --git a/docs/subsystems/tools.i18n.yaml b/docs/subsystems/tools.i18n.yaml index fbf617f20a..90ee0c989f 100644 --- a/docs/subsystems/tools.i18n.yaml +++ b/docs/subsystems/tools.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/subsystems/tools.md -tools.md: 6ff2d967c5631d096dd78236ffd0383ddb2b0493 -tools.zh.md: 82ade5d8d4117387138296cf54fbc8e88ad335e7 +tools.md: 4e56d420f9ba9541725e41e4da87846654206119 +tools.zh.md: f6eee0f0f9b3c549679cc4437755c749caf68c9c diff --git a/docs/subsystems/tools.md b/docs/subsystems/tools.md index 6ff2d967c5..4e56d420f9 100644 --- a/docs/subsystems/tools.md +++ b/docs/subsystems/tools.md @@ -480,12 +480,14 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v ```ts cordis-catalog /** - * Present this agent's tools in `mode` instead of the deployment default. + * Present the calling scope's tools in `mode` instead of the deployment + * default. Nearest scope on the chain wins, so a preset's standing + * declaration covers every agent joined under it. * - * Scoped only, and one declaration per agent: this is how an agent preset - * composes a Code Mode agent beside native ones in the same process, and a + * Scoped only, and one declaration per scope: this is how an agent preset + * composes Code Mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. - * @param mode - the presentation this agent's model sees. + * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. */ presentAs(mode: ToolPresentationMode): () => void diff --git a/docs/subsystems/tools.zh.md b/docs/subsystems/tools.zh.md index 82ade5d8d4..f6eee0f0f9 100644 --- a/docs/subsystems/tools.zh.md +++ b/docs/subsystems/tools.zh.md @@ -480,12 +480,14 @@ Tool registry and execution pipeline. Scoped registrations shadow globals; one v ```ts cordis-catalog /** - * Present this agent's tools in `mode` instead of the deployment default. + * Present the calling scope's tools in `mode` instead of the deployment + * default. Nearest scope on the chain wins, so a preset's standing + * declaration covers every agent joined under it. * - * Scoped only, and one declaration per agent: this is how an agent preset - * composes a Code Mode agent beside native ones in the same process, and a + * Scoped only, and one declaration per scope: this is how an agent preset + * composes Code Mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. - * @param mode - the presentation this agent's model sees. + * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. */ presentAs(mode: ToolPresentationMode): () => void diff --git a/docs/testing.i18n.yaml b/docs/testing.i18n.yaml index 2b608ec739..c3743e38c0 100644 --- a/docs/testing.i18n.yaml +++ b/docs/testing.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/testing.md -testing.md: f5e8a478ec86c29c52f4127c51682c1c44fd23a7 -testing.zh.md: bd1fa7d23263d7c6e3bed65ef4ed09576ca47cc1 +testing.md: f330bb1e02f3613c63f3989a8f9128f737bf5c52 +testing.zh.md: db6facb4fa4bf07eda0a6ee7e558c8c60d4c331e diff --git a/docs/testing.md b/docs/testing.md index f5e8a478ec..f330bb1e02 100644 --- a/docs/testing.md +++ b/docs/testing.md @@ -9,7 +9,7 @@ How this repo tests, tier by tier, and the rules that keep a green suite meaning - **Unit** (`pnpm run test`): vitest over package and example specs under their `tests/**` directories plus repository script specs under `scripts/**/*.spec.ts`; tests stay with the code area they exercise. Every registry gets an HMR-safety test (dispose the contributing fiber, assert cleanup). Prefer edge cases, error paths, event ordering, concurrency races, and permanent tests for contract regressions (see `packages/core/agent-loop/tests/contract-regressions.spec.ts`). - **Coverage gate** (`pnpm run test:coverage`): the gating run, per-file 100% on `packages/*/*/src`. An uncovered line is often dead code the gate is correctly flagging for deletion, not a missing test to bolt on. Line coverage is necessary, never sufficient — it proves lines ran, not that the feature works as shipped. Per-file 100% on `packages/bash/pwsh-local/src` needs a real `pwsh`: without one its executor suites self-skip and `vitest.config.ts` exempts the file so pwsh-less hosts stay green, while CI runners ship pwsh and enforce the full bar. - **Real-API e2e** (`pnpm run test:e2e`): with-key tests against live provider APIs — the DeepSeek model plus provider-specific smokes that gate on their own keys (`EXA_API_KEY`, `PERPLEXITY_API_KEY`, …); each suite self-skips without its key so keyless CI stays green ([real-API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md)). -- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless backend scenarios boot their explicit example composition through an unexported JSONL test driver, while `apps/cli` separately owns product `dsh run` acceptance. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). +- **Snapshot** (`pnpm run test:snapshot`): keyless expected outputs cover external behavior — transport contracts and presentation, while persisted logs pin assembled backend behavior. ACP boots the real automation-server example, replays a recorded session, and diffs normalized JSON-RPC plus the re-persisted log ([ACP snapshot Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md)); headless backend scenarios boot their explicit example composition through an unexported JSONL test driver, while `apps/cli` separately owns product `dsh --profile headless` acceptance. Use `pnpm run test:snapshot:record` when a model transcript changes and `pnpm run test:snapshot:refresh` when replay input remains valid; review every JSONL and expected-output diff. One ACP scenario (`text-turn`) pins full system-prompt/tool-schema content; other fixtures tokenize it so an edit churns one line ([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md)). - **Web browser snapshot** (`pnpm run test:web`; required Linux PR gate): Chromium compares replayed browser output with `apps/web/tests/snapshots/`. CI forces read-only `DSH_SNAPSHOT=replay`, never writing expected outputs; record/refresh stay local and every diff is reviewed ([web e2e lane](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md), [CI gate decision](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md)). `test:web` [builds first](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md) for plugin CSS. Committed session-format JSONL uses the canonical packed-row layout, and the keyless snapshot gate discovers every such fixture by its `session` header; the [temporary migrator](../scripts/migrate-packed-session-fixtures.ts) rewrites older fixture layouts. diff --git a/docs/testing.zh.md b/docs/testing.zh.md index bd1fa7d232..db6facb4fa 100644 --- a/docs/testing.zh.md +++ b/docs/testing.zh.md @@ -9,7 +9,7 @@ - **单元测试**(`pnpm run test`):vitest 运行包(package)和示例各自的 `tests/**` 目录下的测试,以及匹配 `scripts/**/*.spec.ts` 的仓库脚本测试;测试文件与其所覆盖的代码区域放在一起。每个注册表都有一个 HMR(热模块替换)安全测试(dispose(资源释放)贡献的 fiber,断言清理完成)。优先覆盖边界情况、错误路径、事件顺序、并发竞态,以及针对约定回归的永久测试(见 `packages/core/agent-loop/tests/contract-regressions.spec.ts`)。 - **覆盖率门禁**(`pnpm run test:coverage`):门禁级运行,对 `packages/*/*/src` 按文件 100% 覆盖。未覆盖的行往往是门禁正确标记出的死代码(应删除),而非需要补写的测试。行覆盖率是必要条件,但永远不是充分条件:它证明行被执行过,不证明功能按交付预期工作。`packages/bash/pwsh-local/src` 的按文件 100% 覆盖需要真实的 `pwsh`:缺少它时其 executor 套件会自动跳过,`vitest.config.ts` 会豁免该文件以使无 pwsh 的主机保持绿色,而 CI runner 自带 pwsh,仍按完整标准执行门禁。 - **真实 API e2e**(`pnpm run test:e2e`):带密钥测试调用真实提供方 API,包括 DeepSeek 模型以及各提供方特有的冒烟测试;这些测试各自由自己的密钥控制(`EXA_API_KEY`、`PERPLEXITY_API_KEY` 等),缺少密钥时套件会自动跳过,使 keyless CI 保持绿色([真实 API e2e Agent Note](../.agents/notes/implemented/testing/2026-06-19-real-api-e2e-ci.md))。 -- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输约定与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 后端场景通过未导出的 JSONL 测试 driver 启动各自显式的示例组装,而 `apps/cli` 则单独负责产品 CLI(命令行界面)`dsh run` 的验收。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 +- **快照**(`pnpm run test:snapshot`):无密钥预期输出覆盖对外行为(传输约定与呈现),持久化日志则固定组装后的后端行为。ACP 启动真实的自动化服务器示例、回放录制会话,并对归一化 JSON-RPC 与重新持久化的日志执行 diff([ACP 快照 Agent Note](../.agents/notes/implemented/testing/2026-06-19-acp-snapshot-tests.md));headless 后端场景通过未导出的 JSONL 测试 driver 启动各自显式的示例组装,而 `apps/cli` 则单独负责产品 CLI(命令行界面)`dsh --profile headless` 的验收。当模型 transcript(文本记录)发生变化时使用 `pnpm run test:snapshot:record`,回放输入仍然有效时使用 `pnpm run test:snapshot:refresh`;请审查每一处 JSONL 与预期输出差异。一个 ACP 场景(`text-turn`)固定完整的系统提示词与工具 schema 内容;其他 fixture(测试前置数据)将其 token 化,因此修改只会扰动一行([pinned-header Agent Note](../.agents/notes/archived/testing/2026-07-06-pin-request-header-content-in-one-scenario.md))。 - **Web 浏览器快照**(`pnpm run test:web`;必需的 Linux PR(Pull Request)门禁):Chromium 将回放后的浏览器输出与 `apps/web/tests/snapshots/` 比较。CI 强制只读的 `DSH_SNAPSHOT=replay`,绝不写入预期输出;record/refresh 留在本地,每处 diff 都须评审([web e2e 车道](../.agents/notes/implemented/testing/2026-07-24-web-gui-browser-e2e-lane.md)、[CI 门禁决策](../.agents/notes/implemented/testing/2026-07-30-web-browser-snapshot-ci-gate.md))。`test:web` 会[先构建](../.agents/notes/implemented/bug-fix/2026-07-28-themed-scrollbars-and-reserved-gutter.md)以交付插件 CSS。 签入仓库的会话格式 JSONL 使用规范打包行布局,无密钥快照门禁会通过 `session` header 发现每一份此类 fixture;[临时迁移器](../scripts/migrate-packed-session-fixtures.ts)会改写旧版 fixture 布局。 diff --git a/docs/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 567f95d408..7f4ebf012f 100644 --- a/docs/tool-catalog.i18n.yaml +++ b/docs/tool-catalog.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/tool-catalog.md -tool-catalog.md: dbab9ce2f389dbfe40e7d753ced995a8a384be17 -tool-catalog.zh.md: e99f8bc78923e616265427c1e0361c832cc0930f +tool-catalog.md: 19a4035fdc1e24d439307d11cb585f689b35043e +tool-catalog.zh.md: e34fbb85152e012592b4e045e2f505ded6175e94 diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index dbab9ce2f3..19a4035fdc 100644 --- a/docs/tool-catalog.md +++ b/docs/tool-catalog.md @@ -23,7 +23,7 @@ This table connects model-visible tool names to the plugin package and service s | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`, `cordis_mount`, `cordis_unmount` | `ctx.tools` | `tool/call`, `tool/result`, `process-local temporary Plugin lifecycle` | - | Not in any shipped tree (a deliberate opt-in — temporary Plugin code reaches the real runtime, see .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). Plugins created by cordis_mount may register ADDITIONAL model-visible tools until unmounted or DSH restarts; a full changed request header logs those tool-set changes. | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`, `ctx.pty`, `an owning Agent at execution time` | `tool/call`, `PTY shell state`, `tool/result` | - | One owner-isolated persistent bash tool; deployment composition supplies the PTY backend and may override the model-facing environment description. | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`, `ctx.fs` | `tool/call`, `fs/observed after view presence/absence, edit absence, or successful mutation`, `tool/result` | - | Standalone view/create/unique literal replace/line insert tool over the filesystem seam; it composes with any shell or terminal surface. | -| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful mutation`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. | +| `@deepseek-ai/dsh-tool-fs` | `edit`, `read`, `read_image`, `write` | `ctx.tools`, `ctx.fs`, `ctx.systemPrompt`, `ctx.attachments (read_image registration)`, `ctx.llm + an image-capable route (read_image execution)` | `tool/call`, `fs/write-intent or fs/edit-intent for mutations`, `fs/observed after read presence/absence or successful file operation`, `durable attachment (read_image)`, `tool/result` | - | The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. | | `@deepseek-ai/dsh-tool-fs-search` | `glob`, `grep` | `ctx.tools`, `ctx.subprocess`, `ctx.systemPrompt` | `tool/call`, `tool/result` | - | glob and grep are unconditional discovery tools that spawn the packaged ripgrep binary (`@vscode/ripgrep`) through ctx.subprocess as ordinary foreground calls (never background tasks) — no host `rg` install and no shell layer. The catalog uses `sampleOverCapGlobResults: true`; deployments must choose that behavior explicitly. Capped results save the complete formatted list through the optional ctx.spillStore backend; returned locators are follow-up-readable/searchable when the backend exposes local paths in co-located deployments. | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`, `terminal_list`, `terminal_open`, `terminal_read`, `terminal_send`, `terminal_signal` | `ctx.tools`, `ctx.pty`, `ctx.systemPrompt`, `ctx.tasks at call time for run_in_background` | `tool/call`, `tool/result` | - | The six terminal tools are opt-in and complement one-shot bash/filesystem tools. `terminal_send(run_in_background: true)` registers with `ctx.tasks`; TUI, named key sequences, BEL, resize, auto-start, and cross-agent sharing are absent from the schema. | | `@deepseek-ai/dsh-tool-goal` | `create_goal`, `get_goal`, `update_goal` | `ctx.tools`, `ctx.agents`, `ctx.goals`, `ctx.systemPrompt`, `a calling Agent in an authorized open turn` | `tool/call`, `goal/change for mutations`, `tool/result` | - | create, edit, pause, and resume require direct-human root authority; complete and blocked also accept the exact current goal round. The default blocked lower bound is three admitted rounds. | @@ -284,7 +284,7 @@ Source: [`packages/self-modification/tool-cordis/src/index.ts`](../packages/self ### `cordis_mount` -Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. +Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. ```json { @@ -485,6 +485,27 @@ Read a UTF-8 text file and return line-numbered content. Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) +### `read_image` + +Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input. + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] +} +``` + +Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) + ### `write` Create or fully replace a UTF-8 text file. @@ -511,7 +532,7 @@ Create or fully replace a UTF-8 text file. Source: [`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) -The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin. +The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input. ## `@deepseek-ai/dsh-tool-fs-search` diff --git a/docs/tool-catalog.zh.md b/docs/tool-catalog.zh.md index e99f8bc789..e34fbb8515 100644 --- a/docs/tool-catalog.zh.md +++ b/docs/tool-catalog.zh.md @@ -25,7 +25,7 @@ | `@deepseek-ai/dsh-tool-cordis` | `cordis_inspect`、`cordis_mount`、`cordis_unmount` | `ctx.tools` | `tool/call`、`tool/result`、`process-local temporary Plugin lifecycle` | - | 不在任何随产品发布的树中,需要有意选择启用;临时 Plugin 代码可以访问真实运行时,见 .agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md。由 cordis_mount 创建的插件在卸载或 DSH 重启之前可以注册**额外的**模型可见工具;发生这类工具集变更时,系统会记录完整且有变动的请求头。 | | `@deepseek-ai/dsh-tool-bash-persistent` | `bash` | `ctx.tools`、`ctx.pty`、`an owning Agent at execution time` | `tool/call`、`PTY shell state`、`tool/result` | - | 一个按所有者隔离的持久 bash 工具;部署组合提供 PTY 后端,并可覆盖面向模型的环境描述。 | | `@deepseek-ai/dsh-tool-str-replace-editor` | `str_replace_editor` | `ctx.tools`、`ctx.fs` | `tool/call`、`fs/observed after view presence/absence, edit absence, or successful mutation`、`tool/result` | - | 基于文件系统 seam 的独立查看/创建/唯一字面量替换/按行插入工具;可与任何 shell 或终端接口组合。 | -| `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful mutation`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。无论是否加载策略插件,上述工具 schema 都完全相同。 | +| `@deepseek-ai/dsh-tool-fs` | `edit`、`read`、`read_image`、`write` | `ctx.tools`、`ctx.fs`、`ctx.systemPrompt`、`ctx.attachments (read_image registration)`、`ctx.llm + an image-capable route (read_image execution)` | `tool/call`、`fs/write-intent or fs/edit-intent for mutations`、`fs/observed after read presence/absence or successful file operation`、`durable attachment (read_image)`、`tool/result` | - | 先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时 `read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 | | `@deepseek-ai/dsh-tool-fs-search` | `glob`、`grep` | `ctx.tools`、`ctx.subprocess`、`ctx.systemPrompt` | `tool/call`、`tool/result` | - | glob 和 grep 是无条件可用的发现工具,通过 ctx.subprocess spawn 随包提供的 ripgrep 二进制文件(`@vscode/ripgrep`),并作为普通前台调用运行,绝不作为后台任务;无需在宿主机安装 `rg`,也不经过 shell 层。本目录使用 `sampleOverCapGlobResults: true`;部署必须显式选择该行为。结果超过上限时,会通过可选的 ctx.spillStore 后端保存完整的格式化列表;在共置部署中,如果后端公开本地路径,返回的定位信息可供后续读取/搜索。 | | `@deepseek-ai/dsh-tool-pty` | `terminal_close`、`terminal_list`、`terminal_open`、`terminal_read`、`terminal_send`、`terminal_signal` | `ctx.tools`、`ctx.pty`、`ctx.systemPrompt`、`ctx.tasks at call time for run_in_background` | `tool/call`、`tool/result` | - | 这 6 个终端工具需要选择启用,用于补充一次性 bash/文件系统工具。`terminal_send(run_in_background: true)` 会注册到 `ctx.tasks`;schema 不包含 TUI、具名按键序列、BEL、调整尺寸、自动启动和跨 agent 共享。 | | `@deepseek-ai/dsh-tool-goal` | `create_goal`、`get_goal`、`update_goal` | `ctx.tools`、`ctx.agents`、`ctx.goals`、`ctx.systemPrompt`、`a calling Agent in an authorized open turn` | `tool/call`、`goal/change for mutations`、`tool/result` | - | create、edit、pause 和 resume 要求直接来自人类的根权限;complete 和 blocked 也接受确切的当前 Goal Round。blocked 的默认下限是 3 个获准的 Round。 | @@ -286,7 +286,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 ### `cordis_mount` -在当前 DSH 进程中挂载临时 Cordis Plugin。它创建的是内存中的运行时 Plugin,而不是已安装或已配置的 Plugin。该插件会在后续轮次中保持活动,直到执行 cordis_unmount、工具集卸载或 DSH 重启。它不会创建文件、安装包、修改 cordis.yml 或个人/项目配置、在重启后保留,也不会自动转为永久插件。若要保留,请让 Agent 通过常规开发工作流实现普通的本地、项目或仓库 Plugin。它可能影响同一进程中的其他会话;沙箱不是安全边界,注入的服务会访问真实运行时。`code` 会立即作为异步 JavaScript 函数的函数体在隔离沙箱中运行,并且**必须** `return` 一个插件。支持两种形式:函数形式 `return (ctx) => { … }`,它不声明 inject,因此可以注册工具、监听事件和提供服务,但访问**任何**服务(例如 ctx.bash)都会抛出异常;仅在不需要服务时使用。对象形式 `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }`,它声明依赖,Cordis 只在服务存在后激活插件;**优先使用**这种形式。你只能访问 inject 中列出的服务:即使未声明的服务存在,访问它也会抛出异常,因为如果提供方被卸载,未声明的依赖将无法清理。代码调用服务**之前**,请读取 cordis_inspect 的 what:"api";它会列出方法签名以及参数/返回值的类型形状,不要猜测字段类型,例如 bash 运行的 stdout 是对象而非字符串。在 `apply` 内,请使用标准 Cordis API:通过 `ctx.on(event, listener)` 观察事件(见 cordis_inspect 的 what:"events"),或调用 `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` 为自己提供新工具;该工具会在你的**下一步骤**可调用。工具参数:每个键**就是**一个属性,即 { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? };每个直接 DSL 对象都声明 additionalProperties: true|false,而 oneOf: [schema, schema, ...] 会取代 type,表示恰好匹配一个成员的联合。也接受原始 JSON Schema { type: 'object', properties, required?: […] } 包装层,其中对象默认开放。工具的 `execute` **必须**返回 `output.schema` 声明的无损 JSON 值;`output.render(args, value)` 单独返回 Native/模型内容块。临时 Plugin 可以**组合**:一个 Plugin 可以通过 `ctx.provide('name', value)` 提供服务,另一个则可声明 `inject: ['name']` 来消费它;消费方会在提供方出现前保持等待,提供方卸载后重新回到等待状态。在 `apply` 中注册的一切都会由 cordis_unmount 自动清理。沙箱全局对象:`console`(带 `[cordis:]` 标签,写入 harness 终端)、`harness.defineTool`、`harness.registerTool`、`btoa`、`atob`、`TextEncoder`、`TextDecoder`。Node API 已**禁用**:文件系统/网络/定时工作必须通过 Cordis 服务完成,绝不能使用 Node 内置能力;`require`、`setTimeout`/`setInterval` 和 `fetch` 会抛出重定向错误,`process` 和 `Buffer` 未定义。应改用 inject: ['fs'] + ctx.fs 处理文件、inject: ['web'] + ctx.web 处理 HTTP、inject: ['bash'] + ctx.bash 处理进程、inject: ['timer'] + ctx.setTimeout/ctx.setInterval 处理定时(这些是 fiber effect,卸载时自动清理);cordis_inspect 的 what:"api" 会展示**当前**运行时提供的能力。请编写**纯** JavaScript,不要使用 TypeScript(不得使用 `as` 或类型注解)。注意事项:(1) waterfall(瀑布式事件)事件(例如 tools/pre-execute)会向监听器传入最后一个 `next` 回调,该回调**必须**被调用;不调用 `next()` 就返回会**短路**此次调用。除非你有意拦截,否则请优先使用普通通知事件。(2) 切勿等待只能在当前轮次之后解析的内容;你的代码运行在该轮次的工具调用**内部**,否则会死锁。(3) 你的 `ctx` 是受限门面:可以注册工具、观察事件、提供/消费服务和使用定时器,但不会提供框架内部能力(ctx.root、ctx.fiber、ctx.extend、ctx.plugin 等)。不过,它并非安全边界:你注入的服务(例如 ctx.bash)会访问真实运行时。 +在当前 DSH 进程中挂载临时 Cordis Plugin。它创建的是内存中的运行时 Plugin,而不是已安装或已配置的 Plugin。该插件会在后续轮次中保持活动,直到执行 cordis_unmount、工具集卸载或 DSH 重启。它不会创建文件、安装包、修改 cordis.yml 或个人/项目配置、在重启后保留,也不会自动转为永久插件。若要保留,请让 Agent 通过常规开发工作流实现 SDK Plugin 或可安装的 profile bundle。它可能影响同一进程中的其他会话;沙箱不是安全边界,注入的服务会访问真实运行时。`code` 会立即作为异步 JavaScript 函数的函数体在隔离沙箱中运行,并且**必须** `return` 一个插件。支持两种形式:函数形式 `return (ctx) => { … }`,它不声明 inject,因此可以注册工具、监听事件和提供服务,但访问**任何**服务(例如 ctx.bash)都会抛出异常;仅在不需要服务时使用。对象形式 `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }`,它声明依赖,Cordis 只在服务存在后激活插件;**优先使用**这种形式。你只能访问 inject 中列出的服务:即使未声明的服务存在,访问它也会抛出异常,因为如果提供方被卸载,未声明的依赖将无法清理。代码调用服务**之前**,请读取 cordis_inspect 的 what:"api";它会列出方法签名以及参数/返回值的类型形状,不要猜测字段类型,例如 bash 运行的 stdout 是对象而非字符串。在 `apply` 内,请使用标准 Cordis API:通过 `ctx.on(event, listener)` 观察事件(见 cordis_inspect 的 what:"events"),或调用 `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` 为自己提供新工具;该工具会在你的**下一步骤**可调用。工具参数:每个键**就是**一个属性,即 { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? };每个直接 DSL 对象都声明 additionalProperties: true|false,而 oneOf: [schema, schema, ...] 会取代 type,表示恰好匹配一个成员的联合。也接受原始 JSON Schema { type: 'object', properties, required?: […] } 包装层,其中对象默认开放。工具的 `execute` **必须**返回 `output.schema` 声明的无损 JSON 值;`output.render(args, value)` 单独返回 Native/模型内容块。临时 Plugin 可以**组合**:一个 Plugin 可以通过 `ctx.provide('name', value)` 提供服务,另一个则可声明 `inject: ['name']` 来消费它;消费方会在提供方出现前保持等待,提供方卸载后重新回到等待状态。在 `apply` 中注册的一切都会由 cordis_unmount 自动清理。沙箱全局对象:`console`(带 `[cordis:]` 标签,写入 harness 终端)、`harness.defineTool`、`harness.registerTool`、`btoa`、`atob`、`TextEncoder`、`TextDecoder`。Node API 已**禁用**:文件系统/网络/定时工作必须通过 Cordis 服务完成,绝不能使用 Node 内置能力;`require`、`setTimeout`/`setInterval` 和 `fetch` 会抛出重定向错误,`process` 和 `Buffer` 未定义。应改用 inject: ['fs'] + ctx.fs 处理文件、inject: ['web'] + ctx.web 处理 HTTP、inject: ['bash'] + ctx.bash 处理进程、inject: ['timer'] + ctx.setTimeout/ctx.setInterval 处理定时(这些是 fiber effect,卸载时自动清理);cordis_inspect 的 what:"api" 会展示**当前**运行时提供的能力。请编写**纯** JavaScript,不要使用 TypeScript(不得使用 `as` 或类型注解)。注意事项:(1) waterfall(瀑布式事件)事件(例如 tools/pre-execute)会向监听器传入最后一个 `next` 回调,该回调**必须**被调用;不调用 `next()` 就返回会**短路**此次调用。除非你有意拦截,否则请优先使用普通通知事件。(2) 切勿等待只能在当前轮次之后解析的内容;你的代码运行在该轮次的工具调用**内部**,否则会死锁。(3) 你的 `ctx` 是受限门面:可以注册工具、观察事件、提供/消费服务和使用定时器,但不会提供框架内部能力(ctx.root、ctx.fiber、ctx.extend、ctx.plugin 等)。不过,它并非安全边界:你注入的服务(例如 ctx.bash)会访问真实运行时。 ```json { @@ -489,6 +489,27 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) +### `read_image` + +读取 PNG/JPEG/WebP/GIF 文件并返回图像本身。要求当前模型接受图像输入。 + +```json +{ + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] +} +``` + +来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) + ### `write` 创建或完全替换 UTF-8 文本文件。 @@ -515,7 +536,7 @@ pwsh 工具是 Windows 组合中 bash 执行器 seam 的 PowerShell 方言消费 来源:[`packages/fs/tool-fs/src/index.ts`](../packages/fs/tool-fs/src/index.ts) -先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。无论是否加载策略插件,上述工具 schema 都完全相同。 +先读后写/编辑策略由 `@deepseek-ai/dsh-fs-policy` 添加;它是一个 `fs/*` 事件门禁插件,不会改变 schema。加载这些工具的部署按预期也应加载该插件。没有 `ctx.attachments` 时 `read_image` 不会注册;其 schema 与路由无关,执行时除非确切路由的模型声明图像输入,否则拒绝。 ## `@deepseek-ai/dsh-tool-fs-search` diff --git a/docs/user/develop/basic/config.i18n.yaml b/docs/user/develop/basic/config.i18n.yaml index 2887d77209..b367fbb82e 100644 --- a/docs/user/develop/basic/config.i18n.yaml +++ b/docs/user/develop/basic/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/config.md -config.md: 02998c32415b5ba7acf82700034cabc1f7314f33 -config.zh.md: 161af5d6703b4cc77d63443a5a80846593d1c7fd +config.md: 21ba39fd7de1795e9139aff3e2b11743eedd4833 +config.zh.md: a882c4d59b0ac8e8ec27a5b32da5376b534a7f62 diff --git a/docs/user/develop/basic/config.md b/docs/user/develop/basic/config.md index 02998c3241..21ba39fd7d 100644 --- a/docs/user/develop/basic/config.md +++ b/docs/user/develop/basic/config.md @@ -9,8 +9,8 @@ Accept configuration supplied through `cordis.yml`. Export a `Config` type and a same-named Schemastery schema. Put defaults directly on the schema fields: ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' export const name = 'my-plugin' @@ -49,8 +49,8 @@ When loading the plugin, Cordis uses the exported schema to validate configurati Use Schemastery to express stricter validation: ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' export const name = 'validated-plugin' diff --git a/docs/user/develop/basic/config.zh.md b/docs/user/develop/basic/config.zh.md index 161af5d670..a882c4d59b 100644 --- a/docs/user/develop/basic/config.zh.md +++ b/docs/user/develop/basic/config.zh.md @@ -9,8 +9,8 @@ 在插件中导出一个 `Config` 类型和同名的 Schemastery schema;默认值直接写在 schema 中: ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' export const name = 'my-plugin' @@ -49,8 +49,8 @@ export function apply(ctx: Context, config: Config) { 对于需要严格校验的场景,使用 Schemastery 定义 schema: ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' export const name = 'validated-plugin' diff --git a/docs/user/develop/basic/index.i18n.yaml b/docs/user/develop/basic/index.i18n.yaml index 0d89e9622e..6684d18070 100644 --- a/docs/user/develop/basic/index.i18n.yaml +++ b/docs/user/develop/basic/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/index.md -index.md: 7fe66bb19ddb978a4b5a96768b62151b97bca0ec -index.zh.md: 59f4e5b58b6cf1fbc15de8fafb5f4b0db2e220d6 +index.md: e57a42b42690bd92450cc26876c13a1622bb80cc +index.zh.md: 240623341618acd6501f2897ae2844fde0a5b73b diff --git a/docs/user/develop/basic/index.md b/docs/user/develop/basic/index.md index 7fe66bb19d..e57a42b426 100644 --- a/docs/user/develop/basic/index.md +++ b/docs/user/develop/basic/index.md @@ -17,7 +17,7 @@ mkdir -p scratch-plugin/src In Harness, a plugin is a TypeScript module that exports an `apply` function. The framework calls `apply` when loading the plugin and passes a `ctx` context object through which the plugin registers capabilities: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'my-plugin' @@ -33,7 +33,7 @@ That is the complete configuration. Create `scratch-plugin/src/my-plugin.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'hello-plugin' @@ -56,7 +56,7 @@ Create `scratch-plugin/cordis.yml` as a Web overlay that inserts the local plugi Start the Web UI with that overlay: ```sh -pnpm run dsh web --patch ./scratch-plugin/cordis.yml +pnpm dsh web --patch ./scratch-plugin/cordis.yml ``` Open `http://127.0.0.1:3080`. The terminal prints `[hello-plugin] plugin loaded!` during startup. @@ -68,7 +68,7 @@ Anything registered through `ctx`—event listeners, tools, or timers—is clean For a resource that needs explicit cleanup, such as a network connection, use `ctx.effect()` to provide its disposer: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export function apply(ctx: Context) { ctx.effect(() => { @@ -87,7 +87,7 @@ export function apply(ctx: Context) { If the plugin consumes another service such as `tools` or `llm`, declare it in `inject`: ```ts ignore-check -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'my-tool-plugin' export const inject = ['tools'] @@ -107,7 +107,7 @@ In addition to a function module, a plugin can use object or class form. ### Object form ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export default { name: 'my-plugin', @@ -121,7 +121,7 @@ export default { ### Class form ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' export default class MyService extends Service { static inject = ['tools'] diff --git a/docs/user/develop/basic/index.zh.md b/docs/user/develop/basic/index.zh.md index 59f4e5b58b..2406233416 100644 --- a/docs/user/develop/basic/index.zh.md +++ b/docs/user/develop/basic/index.zh.md @@ -17,7 +17,7 @@ mkdir -p scratch-plugin/src 在 Harness 中,插件是一个导出 `apply` 函数的 TypeScript 模块。框架在加载时调用 `apply`,传入一个 `ctx`(上下文对象),你通过 `ctx` 注册能力: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'my-plugin' @@ -33,7 +33,7 @@ export function apply(ctx: Context) { 创建 `scratch-plugin/src/my-plugin.ts`: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'hello-plugin' @@ -56,7 +56,7 @@ export function apply(ctx: Context) { 使用该覆盖层启动 Web UI: ```sh -pnpm run dsh web --patch ./scratch-plugin/cordis.yml +pnpm dsh web --patch ./scratch-plugin/cordis.yml ``` 打开 `http://127.0.0.1:3080`。启动期间,终端会打印 `[hello-plugin] plugin loaded!`。 @@ -68,7 +68,7 @@ pnpm run dsh web --patch ./scratch-plugin/cordis.yml 如果你有需要手动清理的资源(比如一个网络连接),用 `ctx.effect()` 告诉框架怎么清理: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export function apply(ctx: Context) { ctx.effect(() => { @@ -87,7 +87,7 @@ export function apply(ctx: Context) { 如果你的插件需要使用其他服务(如 `tools`、`llm`),需要声明 `inject`: ```ts ignore-check -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = 'my-tool-plugin' export const inject = ['tools'] @@ -107,7 +107,7 @@ export function apply(ctx: Context) { ### 对象形式 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export default { name: 'my-plugin', @@ -121,7 +121,7 @@ export default { ### 类形式 ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' export default class MyService extends Service { static inject = ['tools'] diff --git a/docs/user/develop/basic/publish.i18n.yaml b/docs/user/develop/basic/publish.i18n.yaml index d849ac4ae0..91dba947bb 100644 --- a/docs/user/develop/basic/publish.i18n.yaml +++ b/docs/user/develop/basic/publish.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/publish.md -publish.md: 7657654b1467c14b22e0eb6372c2bc4e77db2f38 -publish.zh.md: 7af2ae3a06cc74597d5cbd6fddd46fbab069e287 +publish.md: 8437c7ea5c4cb966f9f3d68977949c78986ec9a5 +publish.zh.md: 4409dbfda060a84b316029d87ec985209cfa286a diff --git a/docs/user/develop/basic/publish.md b/docs/user/develop/basic/publish.md index 7657654b14..8437c7ea5c 100644 --- a/docs/user/develop/basic/publish.md +++ b/docs/user/develop/basic/publish.md @@ -98,7 +98,8 @@ The effective configuration composes over an empty root by applying, in order: 2. The profile's own `cordis.patch.yml`. 3. The home-level `$DSH_HOME/cordis.patch.yml` — machine-local preferences shared by every profile. 4. Each `--patch ` overlay, in argv order. -5. Launcher flag patches (for example `dsh web --port`). + +App arguments are not another patch layer. A surface bundle can resolve them through an ordinary app-owned service, described below. Later layers win per row, and a patch replaces a row's entire `config` value rather than deep-merging keys. Two consequences for bundle authors: @@ -107,6 +108,29 @@ Later layers win per row, and a patch replaces a row's entire `config` value rat In-box bundle names always resolve from the dsh installation itself; pnpm manages only out-of-tree packages, so your bundle can rely on `@deepseek-ai/dsh-base` being present and current. +## Give a surface bundle its own command line + +A bundle that defines a runnable app mounts an ordinary provider plugin: + +```yaml +- id: hello-startup + name: 'dsh-hello-plugin/startup' +``` + +The plugin exports `inject = ['cmdlineArgs']`, calls `parseCmdline` from [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) with its own commander program, and provides the returned value as its app-owned service. The launcher hands every plugin the same immutable arguments after launcher flags, so app-specific flags need no launcher change and multiple plugins may parse the snapshot. The Loader row needs no launcher marker or special kind. + +Rows configured by those arguments inject the provider's service and read it from their own `!!js` options, with the deployment value beside it as the fallback: + +```yaml +- id: my-app + name: '@example/my-app' + inject: [myAppStartup] + config: + port: !!js ctx.myAppStartup.port ?? 8080 +``` + +On `--help`, the provider publishes no service, so those rows never activate. Loader mounts the composition once, waits for each row's ordinary injections, and only then evaluates that row's `!!js` config against its injected context. + ## Installing from GitHub: the build-script catch Publishing to a registry is not required — users can install straight from a git host: diff --git a/docs/user/develop/basic/publish.zh.md b/docs/user/develop/basic/publish.zh.md index 7af2ae3a06..4409dbfda0 100644 --- a/docs/user/develop/basic/publish.zh.md +++ b/docs/user/develop/basic/publish.zh.md @@ -2,14 +2,14 @@ [English](publish.md) | 中文 -前几篇教程通过 `--patch` overlay 加载本地插件。本教程把它打包成可安装的**组合包**,用 `dsh plugin add` 安装进一个 **profile**,并解释决定组合后配置的层顺序。请先完成[插件配置](./config.md)。 +前几篇教程通过 `--patch` overlay 加载本地插件。本教程把它打包成可安装的**组合包**(bundle),用 `dsh plugin add` 安装进一个 **profile**,并解释决定组合后配置的层顺序。请先完成[插件配置](./config.md)。 -## 两个概念,两种 manifest(元数据清单) +## 两个概念,两种 manifest -安装机制建立在两个概念之上。二者都由一份 `package.json` 描述,但它们在 `dsh` 键下携带的 manifest 种类不同,回答的问题也不同: +安装机制建立在两个概念之上。二者都由一份 `package.json` 描述,但它们在 `dsh` 键下携带的 manifest(元数据清单)种类不同,回答的问题也不同: -- **组合包**是附带一个配置层的 npm 包。它的 manifest 声明 `dsh.bundle`,回答的是「这个包贡献什么?」:一个插入或覆盖插件行的 patch 文件。 -- **profile** 是位于 `$DSH_HOME/profiles/` 下、描述一份可启动组合的目录。它的 manifest 声明 `dsh.profile`,回答的是「这套配置由哪些组合包按什么顺序组成?」。 +- **组合包**是附带一个配置层的 npm 包。它的 manifest 声明 `dsh.bundle`,回答的是"这个包贡献什么?":一个插入或覆盖插件行的 patch 文件。 +- **profile** 是位于 `$DSH_HOME/profiles/` 下、描述一份可启动组合的目录。它的 manifest 声明 `dsh.profile`,回答的是"这套配置由哪些组合包按什么顺序组成?"。 组合包是你编写并分发的东西;profile 是用户用 `dsh --profile ` 启动的东西。没有东西同时是两者。 @@ -98,7 +98,8 @@ dsh --profile demo 2. profile 自己的 `cordis.patch.yml`。 3. home 级的 `$DSH_HOME/cordis.patch.yml`——各 profile 共享的机器本地偏好。 4. 每个 `--patch ` overlay,按 argv 顺序。 -5. 启动器 flag patch(例如 `dsh web --port`)。 + +应用参数不是另一层 patch。表层组合包可以通过下文所述的普通应用自有服务解析它们。 后应用的层按行胜出,且 patch 会替换目标行的整个 `config` 值,而不是深度合并各键。这给组合包作者带来两个推论: @@ -107,6 +108,29 @@ dsh --profile demo 内置组合包名称始终从 dsh 安装目录本身解析;pnpm 只管理树外的包,所以你的组合包可以放心依赖 `@deepseek-ai/dsh-base` 存在且与安装保持一致。 +## 让表层组合包持有自己的命令行 + +定义了可运行应用的组合包挂载一个普通提供方插件: + +```yaml +- id: hello-startup + name: 'dsh-hello-plugin/startup' +``` + +该插件导出 `inject = ['cmdlineArgs']`,使用自己的 commander program 调用 [`@deepseek-ai/dsh-cmdline`](../../../../packages/boot/cmdline/README.md) 中的 `parseCmdline`,再把返回值作为应用自有服务提供出去。启动器把自身 flag 之后的同一份不可变参数交给每个插件,因此添加应用专属 flag 无需修改启动器,多个插件也可以解析该快照。Loader 行不需要启动器标记或特殊类型。 + +受这些参数配置的行会注入提供方服务,并在自己的 `!!js` 选项中读取它,同时把部署取值写在旁边作为回退: + +```yaml +- id: my-app + name: '@example/my-app' + inject: [myAppStartup] + config: + port: !!js ctx.myAppStartup.port ?? 8080 +``` + +遇到 `--help` 时,提供方不会发布该服务,所以这些行不会激活。Loader 只挂载一次组合,等待每一行的普通注入,再基于其已注入的上下文求值该行的 `!!js` 配置。 + ## 从 GitHub 安装:构建脚本这道坎 发布到注册表不是必须的——用户可以直接从 git 托管安装: @@ -127,7 +151,7 @@ dsh plugin --profile demo add github:you/hello-plugin 然后重新执行 `add`。 -请如实看待这项授权:**允许该包的代码在安装时于你的机器上执行**,且不在 agent(智能体)运行的任何沙箱之内。只对源码可信的包授权,并锁定 commit(`github:you/hello-plugin#`),让后续推送无法悄悄改变实际运行的内容。 +请如实看待这项授权:**允许该包的代码在安装时于你的机器上执行**,且不在 agent 运行的任何沙箱之内。只对源码可信的包授权,并锁定 commit(`github:you/hello-plugin#`),让后续推送无法悄悄改变实际运行的内容。 如果不想让用户做这项授权,就改为分发构建产物——以下两种形式都不需要任何构建权限: diff --git a/docs/user/develop/basic/tool.i18n.yaml b/docs/user/develop/basic/tool.i18n.yaml index 594e2c2872..2fdba218d0 100644 --- a/docs/user/develop/basic/tool.i18n.yaml +++ b/docs/user/develop/basic/tool.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/basic/tool.md -tool.md: ba2f3b1302ba31735d67be264f498a0395394d06 -tool.zh.md: 676f8fc996d752a05d94b55d4522e61e3b3e2161 +tool.md: a149f6876c573c9ece4b066ef6c211f69b0b03a8 +tool.zh.md: 1f9a1172bcde33a1d7319492f15686c47de01174 diff --git a/docs/user/develop/basic/tool.md b/docs/user/develop/basic/tool.md index ba2f3b1302..a149f6876c 100644 --- a/docs/user/develop/basic/tool.md +++ b/docs/user/develop/basic/tool.md @@ -9,7 +9,7 @@ This tutorial adds a `greet` tool to the Web UI. Complete [Your first plugin](./ Replace `scratch-plugin/src/my-plugin.ts` with: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'greet-tool' @@ -40,7 +40,7 @@ export function apply(ctx: Context) { Restart the development command if it is not running: ```sh -pnpm run dsh web --patch ./scratch-plugin/cordis.yml +pnpm dsh web --patch ./scratch-plugin/cordis.yml ``` Open `http://127.0.0.1:3080` and ask: `Use the greet tool to greet Ada.` The model can call `greet` and receives `Hello, Ada!` as the tool result. diff --git a/docs/user/develop/basic/tool.zh.md b/docs/user/develop/basic/tool.zh.md index 676f8fc996..1f9a1172bc 100644 --- a/docs/user/develop/basic/tool.zh.md +++ b/docs/user/develop/basic/tool.zh.md @@ -9,7 +9,7 @@ 将 `scratch-plugin/src/my-plugin.ts` 替换为: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'greet-tool' @@ -40,7 +40,7 @@ export function apply(ctx: Context) { 如果开发命令未在运行,请重新启动: ```sh -pnpm run dsh web --patch ./scratch-plugin/cordis.yml +pnpm dsh web --patch ./scratch-plugin/cordis.yml ``` 打开 `http://127.0.0.1:3080`,然后输入:`Use the greet tool to greet Ada.` 模型可以调用 `greet`,并收到 `Hello, Ada!` 这一工具结果。 diff --git a/docs/user/develop/framework/events.i18n.yaml b/docs/user/develop/framework/events.i18n.yaml index f39b04145d..ebc4dc7833 100644 --- a/docs/user/develop/framework/events.i18n.yaml +++ b/docs/user/develop/framework/events.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/framework/events.md -events.md: 8a8c076d9c7b40d73182db074c4f494fded8c6dd -events.zh.md: 9649d89d575a1b05fa524bd460043da71dc9ae43 +events.md: 4b5f9ee215186398ee5aee7a438f792f9b5a3639 +events.zh.md: b48c8020803239d3c9636f81052d3b70afa315f2 diff --git a/docs/user/develop/framework/events.md b/docs/user/develop/framework/events.md index 8a8c076d9c..4b5f9ee215 100644 --- a/docs/user/develop/framework/events.md +++ b/docs/user/develop/framework/events.md @@ -85,9 +85,9 @@ A waterfall listener **must call `next()`**. Omitting it short-circuits the pipe Harness uses TypeScript declaration merging for type-safe events: ```ts -import 'cordis' +import '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { 'my-plugin/ready': (payload: { id: string }) => void 'my-plugin/check': (input: string) => boolean | undefined @@ -121,7 +121,7 @@ export function apply(ctx: Context) { This plugin logs tool calls and results: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import '@deepseek-ai/dsh-tools' export const name = 'tool-logger' diff --git a/docs/user/develop/framework/events.zh.md b/docs/user/develop/framework/events.zh.md index 9649d89d57..b48c802080 100644 --- a/docs/user/develop/framework/events.zh.md +++ b/docs/user/develop/framework/events.zh.md @@ -85,9 +85,9 @@ waterfall 监听器**必须调用 `next()`**。不调用 `next` 会短路整个 Harness 使用 TypeScript 声明合并来为事件提供类型安全: ```ts -import 'cordis' +import '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { 'my-plugin/ready': (payload: { id: string }) => void 'my-plugin/check': (input: string) => boolean | undefined @@ -121,7 +121,7 @@ export function apply(ctx: Context) { 这个插件记录工具调用和工具结果: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import '@deepseek-ai/dsh-tools' export const name = 'tool-logger' diff --git a/docs/user/develop/framework/index.i18n.yaml b/docs/user/develop/framework/index.i18n.yaml index d06be13bdd..1c8dc3dae4 100644 --- a/docs/user/develop/framework/index.i18n.yaml +++ b/docs/user/develop/framework/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/framework/index.md -index.md: 79e925b54509da41535735527e283850384257ec -index.zh.md: 962677dc468c9cc233a51d50758247e028d9c3ed +index.md: 85701ce281d92da0c805b39291179df73eb65f51 +index.zh.md: 871aa55ef81a7dcbfe3cbde5986244220ee32f98 diff --git a/docs/user/develop/framework/index.md b/docs/user/develop/framework/index.md index 79e925b545..85701ce281 100644 --- a/docs/user/develop/framework/index.md +++ b/docs/user/develop/framework/index.md @@ -80,7 +80,7 @@ export function apply(ctx: Context) { To stop a plugin instance early: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' declare const ctx: Context declare function myPlugin(ctx: Context): void @@ -98,7 +98,7 @@ await fiber.dispose() ## Hot replacement (HMR) -With `@cordisjs/plugin-hmr` loaded from `cordis.yml`, editing a plugin source file triggers: +With `@deepseek-ai/cordis-plugin-hmr` loaded from `cordis.yml`, editing a plugin source file triggers: 1. Unload the old plugin and clean up its registrations. 2. Load the new code. diff --git a/docs/user/develop/framework/index.zh.md b/docs/user/develop/framework/index.zh.md index 962677dc46..871aa55ef8 100644 --- a/docs/user/develop/framework/index.zh.md +++ b/docs/user/develop/framework/index.zh.md @@ -80,7 +80,7 @@ export function apply(ctx: Context) { 当你需要提前终止一个插件实例: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' declare const ctx: Context declare function myPlugin(ctx: Context): void @@ -98,7 +98,7 @@ await fiber.dispose() ## HMR(热模块替换) -通过 `cordis.yml` 加载 `@cordisjs/plugin-hmr` 后,修改插件源文件会触发: +通过 `cordis.yml` 加载 `@deepseek-ai/cordis-plugin-hmr` 后,修改插件源文件会触发: 1. 卸载旧插件(清理所有注册) 2. 重新加载新代码 diff --git a/docs/user/develop/framework/service.i18n.yaml b/docs/user/develop/framework/service.i18n.yaml index 7cb2f4ff88..29151de351 100644 --- a/docs/user/develop/framework/service.i18n.yaml +++ b/docs/user/develop/framework/service.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/framework/service.md -service.md: 040b1388cc431c30045e05f7d372ab5885bb3f9d -service.zh.md: 0786b684c1688440a24cc729288835ad636f8ff7 +service.md: 3358f82ca5391741a7f531b7504de7335959ad03 +service.zh.md: 8fb4beeac43051c0f08483fe61e22c588127c360 diff --git a/docs/user/develop/framework/service.md b/docs/user/develop/framework/service.md index 040b1388cc..3358f82ca5 100644 --- a/docs/user/develop/framework/service.md +++ b/docs/user/develop/framework/service.md @@ -36,7 +36,7 @@ When `apply` runs, every service declared by `inject` is ready. If a service is ### Extend Service ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' export default class MetricsService extends Service { static inject = ['llm'] // A service may depend on other services. @@ -67,9 +67,9 @@ export function apply(ctx: Context) { Use TypeScript declaration merging to type `ctx.metrics`: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { metrics: MetricsService } @@ -114,7 +114,7 @@ This prevents a plugin from calling a service that no longer exists. ```yaml - id: group-a - name: '@cordisjs/plugin-group' + name: '@deepseek-ai/cordis-plugin-group' group: true isolate: bash: true @@ -125,7 +125,7 @@ This prevents a plugin from calling a service that no longer exists. - name: './src/plugin-a.ts' - id: group-b - name: '@cordisjs/plugin-group' + name: '@deepseek-ai/cordis-plugin-group' group: true isolate: bash: true diff --git a/docs/user/develop/framework/service.zh.md b/docs/user/develop/framework/service.zh.md index 0786b684c1..8fb4beeac4 100644 --- a/docs/user/develop/framework/service.zh.md +++ b/docs/user/develop/framework/service.zh.md @@ -36,7 +36,7 @@ export function apply(ctx: Context) { ### 使用 Service 基类 ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' export default class MetricsService extends Service { static inject = ['llm'] // A service may depend on other services. @@ -67,9 +67,9 @@ export function apply(ctx: Context) { 使用 TypeScript 声明合并让 `ctx.metrics` 有正确类型: ```ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { metrics: MetricsService } @@ -114,7 +114,7 @@ export function apply(ctx: Context) { ```yaml - id: group-a - name: '@cordisjs/plugin-group' + name: '@deepseek-ai/cordis-plugin-group' group: true isolate: bash: true @@ -125,7 +125,7 @@ export function apply(ctx: Context) { - name: './src/plugin-a.ts' - id: group-b - name: '@cordisjs/plugin-group' + name: '@deepseek-ai/cordis-plugin-group' group: true isolate: bash: true diff --git a/docs/user/develop/practice/index.i18n.yaml b/docs/user/develop/practice/index.i18n.yaml index fc15dfeb2f..1bb86a5cbb 100644 --- a/docs/user/develop/practice/index.i18n.yaml +++ b/docs/user/develop/practice/index.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/practice/index.md -index.md: 1eb33e17ab6c5d0a2b37ff97d5948dfbcba497ca -index.zh.md: 31afa80407f81f571615b5ed68a9370775f5188f +index.md: 7ca9f0b1abe472dc90c6d4e56543e43b6d2ec727 +index.zh.md: 216b1cb01b355e411bf1949c59fd3720ae47139e diff --git a/docs/user/develop/practice/index.md b/docs/user/develop/practice/index.md index 1eb33e17ab..7ca9f0b1ab 100644 --- a/docs/user/develop/practice/index.md +++ b/docs/user/develop/practice/index.md @@ -61,9 +61,9 @@ The [capability-seam reference](../../../capability-seams.md) owns the current b ```ts ignore-check // packages/my-cap/my-cap/src/index.ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { myCap: MyCapService } @@ -91,7 +91,7 @@ export interface MyCapResult { ```ts ignore-check // packages/my-cap/my-cap-local/src/index.ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap' class MyCapLocal extends MyCapService { @@ -112,7 +112,7 @@ export function apply(ctx: Context) { ```ts ignore-check // packages/my-cap/tool-my-cap/src/index.ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'tool-my-cap' diff --git a/docs/user/develop/practice/index.zh.md b/docs/user/develop/practice/index.zh.md index 31afa80407..216b1cb01b 100644 --- a/docs/user/develop/practice/index.zh.md +++ b/docs/user/develop/practice/index.zh.md @@ -61,9 +61,9 @@ ```ts ignore-check // packages/my-cap/my-cap/src/index.ts -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { myCap: MyCapService } @@ -91,7 +91,7 @@ export interface MyCapResult { ```ts ignore-check // packages/my-cap/my-cap-local/src/index.ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { MyCapService, type MyCapRequest, type MyCapResult } from '@deepseek-ai/dsh-my-cap' class MyCapLocal extends MyCapService { @@ -112,7 +112,7 @@ export function apply(ctx: Context) { ```ts ignore-check // packages/my-cap/tool-my-cap/src/index.ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = 'tool-my-cap' diff --git a/docs/user/develop/practice/llm-adapter.i18n.yaml b/docs/user/develop/practice/llm-adapter.i18n.yaml index a7a745faaf..c8487899ec 100644 --- a/docs/user/develop/practice/llm-adapter.i18n.yaml +++ b/docs/user/develop/practice/llm-adapter.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/develop/practice/llm-adapter.md -llm-adapter.md: 7445688530c1ba61e5c065f9f5e49db6498da5b1 -llm-adapter.zh.md: c726735ff2679584d1c061ef8acddc8981dadd26 +llm-adapter.md: aba4a6d0c8ee42e78ca5a804d9a0dd9b31c1e240 +llm-adapter.zh.md: dff9eef464599823d6cd99e83d668485109b2ec0 diff --git a/docs/user/develop/practice/llm-adapter.md b/docs/user/develop/practice/llm-adapter.md index 7445688530..aba4a6d0c8 100644 --- a/docs/user/develop/practice/llm-adapter.md +++ b/docs/user/develop/practice/llm-adapter.md @@ -11,8 +11,8 @@ An LLM adapter extends `LlmAdapter` and implements `stream()`, translating Harne ## Minimal implementation ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' class MyAdapter extends LlmAdapter { diff --git a/docs/user/develop/practice/llm-adapter.zh.md b/docs/user/develop/practice/llm-adapter.zh.md index c726735ff2..dff9eef464 100644 --- a/docs/user/develop/practice/llm-adapter.zh.md +++ b/docs/user/develop/practice/llm-adapter.zh.md @@ -11,8 +11,8 @@ LLM 适配器是一个继承 `LlmAdapter` 并实现 `stream()` 方法的类, ## 最小实现 ```ts -import type { Context } from 'cordis' -import Schema from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Schema from '@deepseek-ai/schemastery' import { LlmAdapter, type GenerateOptions, type StreamChunk } from '@deepseek-ai/dsh-llm' class MyAdapter extends LlmAdapter { diff --git a/docs/user/guide/config.i18n.yaml b/docs/user/guide/config.i18n.yaml index 7feb2c7f07..00a8867092 100644 --- a/docs/user/guide/config.i18n.yaml +++ b/docs/user/guide/config.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/config.md -config.md: cd778065801ae58a46703ae3447f835f80abf062 -config.zh.md: 6f6d37bfe8f7ad29c154d65c1763279655006435 +config.md: 1d3ad5ce36d4b360ba5156b6be28a6caae4a23d4 +config.zh.md: 7f8bfaa77066f2976a5667e3ac402814a7afdf96 diff --git a/docs/user/guide/config.md b/docs/user/guide/config.md index cd77806580..1d3ad5ce36 100644 --- a/docs/user/guide/config.md +++ b/docs/user/guide/config.md @@ -18,6 +18,10 @@ A minimal configuration is a list of plugin entries: ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: + - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -47,16 +51,17 @@ Cordis starts sibling entries concurrently. A plugin declares required services ## CLI patch layers -`dsh --profile ` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles//cordis.patch.yml`, then each `--patch ` overlay, then CLI-flag patches. Later layers win per row. +`dsh --profile ` composes the profile's bundle patch layers (its manifest's `dsh.profile.bundles` list, in order) over an empty root, then the profile's own `~/.dsh/profiles//cordis.patch.yml`, the home-level `$DSH_HOME/cordis.patch.yml`, and each `--patch ` overlay. Later layers win per row. App flags are not another patch layer: an ordinary bundle plugin injects `cmdlineArgs` and provides parsed values as its own service, while rows that inject and retain a `!!js` read of that service give the invocation value precedence. -A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKeyEnv` and `baseURL`, so restate every key the row must retain. +A patch replaces a row's entire `config` value; it does not deep-merge keys. For example, patching `llm-deepseek` with only `config: { thinking: disabled }` also removes that row's configured `apiKey` and `baseURL`, so restate every key the row must retain. ## JavaScript values and environment variables -The Cordis loader evaluates runtime expressions tagged with `!!js` for non-secret runtime values. Bundled LLM adapters carry credential references such as `apiKeyEnv`; the value belongs in an environment layer or `$DSH_HOME/.credentials.yaml`, not Cordis configuration. +The Cordis loader evaluates runtime expressions tagged with `!!js`. Keep API keys and other secrets in the gitignored `.env` file at the repository root, never in committed configuration. ```yaml config: + apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` diff --git a/docs/user/guide/config.zh.md b/docs/user/guide/config.zh.md index 6f6d37bfe8..7f8bfaa770 100644 --- a/docs/user/guide/config.zh.md +++ b/docs/user/guide/config.zh.md @@ -18,6 +18,10 @@ Harness 使用 `cordis.yml` 描述 agent(智能体)加载哪些插件以及 ```yaml - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' + config: + apiKey: !!js process.env.DEEPSEEK_API_KEY + models: + - deepseek-v4-flash - id: bash name: '@deepseek-ai/dsh-bash-local' @@ -47,16 +51,17 @@ Cordis 会并发启动同级配置项。插件通过 `inject` 声明必需服务 ## CLI 补丁层 -`dsh --profile ` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles//cordis.patch.yml`、home 级 `$DSH_HOME/cordis.patch.yml`、每个 `--patch ` overlay,最后是 CLI(命令行界面)标志补丁。同一行以较后的层为准。 +`dsh --profile ` 按该 profile 的 manifest(元数据清单)中 `dsh.profile.bundles` 列表的顺序,在空根之上组合各组合包补丁层,随后依次应用该 profile 自己的 `~/.dsh/profiles//cordis.patch.yml`、home 级的 `$DSH_HOME/cordis.patch.yml` 与每个 `--patch ` overlay。同一行以较后的层为准。应用 flag 并不是另一层 patch:组合包中的普通插件注入 `cmdlineArgs`,再把解析值作为自身服务提供;注入该服务并保留其 `!!js` 读取的行会让本次调用的取值优先。 -补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKeyEnv` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 +补丁会替换目标行的整个 `config` 值,而不是深度合并各个键。例如,只用 `config: { thinking: disabled }` 修补 `llm-deepseek`,也会移除该行原有的 `apiKey` 与 `baseURL`;因此必须重新写出该行需要保留的全部键。 ## JavaScript 值和环境变量 -Cordis loader 会求值以 `!!js` 标记的运行时表达式,用于非机密的运行时值。仓库内置的 LLM(大语言模型)适配器携带 `apiKeyEnv` 等凭据引用;对应的值应放在环境层或 `$DSH_HOME/.credentials.yaml`,而不是 Cordis 配置中。 +Cordis loader 使用 `!!js` 标签读取运行时表达式。API key 等凭据应放在仓库根目录、已被 Git 忽略的 `.env` 中,不能提交到配置文件。 ```yaml config: + apiKey: !!js process.env.DEEPSEEK_API_KEY cwd: !!js process.cwd() ``` @@ -64,4 +69,4 @@ config: ## 精确配置参考 -每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../architecture.md)和[能力 seam](../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../examples/README.md)中最接近的例子。 +每个插件当前支持的字段、类型和默认值见自动生成的[插件配置目录](../../config-catalog.md)。理解插件如何组合可继续阅读[架构说明](../../architecture.md)和[能力接口](../../capability-seams.md);要创建自己的配置,优先复制并修改[示例目录说明](../../../examples/README.md)中最接近的例子。 diff --git a/docs/user/guide/providers.i18n.yaml b/docs/user/guide/providers.i18n.yaml index 02e4486480..9da7fd1113 100644 --- a/docs/user/guide/providers.i18n.yaml +++ b/docs/user/guide/providers.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/providers.md -providers.md: b5217ca7eaa00e7f31db4b3269ddb0d489fe3923 -providers.zh.md: a27adefdbb76c43d099982b0503a9adde139597f +providers.md: 0e7ed11d1b09a8361d75b576a400978ac66d08a7 +providers.zh.md: 060bf3dc41b773e89cd0d78de921c3a20cfc6076 diff --git a/docs/user/guide/providers.md b/docs/user/guide/providers.md index b5217ca7ea..0e7ed11d1b 100644 --- a/docs/user/guide/providers.md +++ b/docs/user/guide/providers.md @@ -15,7 +15,7 @@ Adding a provider therefore rarely means editing `cordis.yml` — writing settin ## Configure from the web UI -Start `pnpm run dsh web` and open **Settings → Models**. +Start `pnpm dsh web` and open **Settings → Models**. ![The Models page: the DeepSeek card, with Add provider and Add a custom provider below it](providers-models-page.png) diff --git a/docs/user/guide/providers.zh.md b/docs/user/guide/providers.zh.md index a27adefdbb..060bf3dc41 100644 --- a/docs/user/guide/providers.zh.md +++ b/docs/user/guide/providers.zh.md @@ -15,7 +15,7 @@ Harness 出厂自带 DeepSeek,同时预装了一个通用的多提供方适配 ## 在 Web 界面里配置 -启动 `pnpm run dsh web`,打开**设置 → 模型**。 +启动 `pnpm dsh web`,打开**设置 → 模型**。 ![模型页:DeepSeek 卡片,以及添加提供方与添加自定义提供方两个入口](providers-models-page.zh.png) diff --git a/docs/user/guide/python-sdk.i18n.yaml b/docs/user/guide/python-sdk.i18n.yaml new file mode 100644 index 0000000000..eaf2543f90 --- /dev/null +++ b/docs/user/guide/python-sdk.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/user/guide/python-sdk.md +python-sdk.md: 0713de6f96c110624d5f5a8f2ccf56dd4ce01096 +python-sdk.zh.md: f8fa877b407a43c643fda405e1059fb487182761 diff --git a/docs/user/guide/python-sdk.md b/docs/user/guide/python-sdk.md new file mode 100644 index 0000000000..0713de6f96 --- /dev/null +++ b/docs/user/guide/python-sdk.md @@ -0,0 +1,130 @@ +# Get started with the Python SDK + +English | [中文](python-sdk.zh.md) + +This tutorial installs the Python SDK, runs a checked-in Cordis composition without the Web UI, and uses the same API in your own program. It uses the compact [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) configuration as a complete example with a fixed system prompt, tool catalog, persistent-shell behavior, and compaction policy. + +## Prerequisites + +- Python 3.10 or newer +- Linux x64, Linux arm64, or macOS arm64 +- A DeepSeek-compatible API endpoint and credential +- An isolated workspace that the agent may modify + +## Install the SDK + +Choose either the public package or a source build. Both install the `deepseek-harness-sdk` distribution and expose the `deepseek_harness` Python module. + +### Install from PyPI + +Create a virtual environment and install the SDK with its same-version bundled runtime: + +```sh +python -m venv .venv +. .venv/bin/activate +python -m pip install deepseek-harness-sdk +``` + +### Build from source + +A source build additionally requires Git, Node.js ^22.19 or >= 24, Corepack-enabled pnpm 11, and `uv`. The following commands build the runtime for the current supported host platform, build both wheels, and install them into the active virtual environment: + +```sh +git clone https://github.com/deepseek-ai/deepseek-harness.git deepseek-harness +cd deepseek-harness +python -m pip install uv==0.11.23 +corepack enable +pnpm install + +case "$(uname -s):$(uname -m)" in + Linux:x86_64) runtime_platform=linux-x64 ;; + Linux:aarch64|Linux:arm64) runtime_platform=linux-arm64 ;; + Darwin:arm64) runtime_platform=macos-arm64 ;; + *) echo "unsupported platform" >&2; exit 1 ;; +esac + +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="node24-$runtime_platform" +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py \ + --package runtime \ + --platform "$runtime_platform" \ + --runtime-exe "dist-exe/dsh-jsonrpc-agent-pkg-$runtime_platform" \ + --output-dir dist-python +python -m pip install --find-links dist-python "deepseek-harness-sdk==$version" +``` + +The runtime wheel contains the JSON-RPC executable and every plugin used by the complete [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml), so neither installation path needs Node.js after installation. + +## Run the checked-in example + +Set the credential in the environment. Set `DEEPSEEK_BASE_URL` as well when the model is served by an OpenAI-compatible proxy rather than the default DeepSeek endpoint. + +```sh +export DEEPSEEK_API_KEY=sk-your-key-here +# export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 +``` + +Run one task from the repository checkout: + +```sh +python examples/jsonrpc-agent/minimal.py \ + --workspace /absolute/path/to/workspace \ + --session-root /absolute/path/to/sessions \ + --session-id example-001 \ + "Inspect the repository and fix the failing tests." +``` + +The script prints the final assistant response. The session root receives a JSONL session log containing the assembled model request and every tool call. + +## Use the SDK in your own program + +The example is a thin wrapper around this SDK call: + +```python +from pathlib import Path + +from deepseek_harness import DeepSeekHarness + +config = Path("examples/jsonrpc-agent/minimal.cordis.yml").resolve() +workspace = Path("/absolute/path/to/workspace").resolve() +sessions = Path("/absolute/path/to/sessions").resolve() + +with DeepSeekHarness( + provider="deepseek-official", + model="deepseek-v4-flash", + max_tokens=49_152, + cwd=str(workspace), + session_root=str(sessions), + cordis=str(config), +) as harness: + result = harness.run( + "Inspect the repository and fix the failing tests.", + session_id="example-001", + ) + +print(result.final_response) +``` + +`DeepSeekHarness` starts the bundled JSON-RPC runtime lazily and reuses it until the context manager exits. Reusing the same harness and session id across calls also preserves the session-owned Bash process, including its working directory, exported variables, and shell functions. + +## Understand the example configuration + +| Surface | Fixed value | +|---|---| +| System prompt | `You are a helpful software engineer assistant.` | +| Model-facing tools | Persistent `bash` and `str_replace_editor` only | +| Bash timeout | 300 seconds | +| Editor output limit | 16,000 characters | +| Compaction | Trigger ratio `0.8`, retain `20,480` tokens, summary cap `8,192` tokens, one retry | +| Session persistence | Uncompressed JSONL under `DSH_SESSION_ROOT` | + +The configuration omits harness identity, workspace prompt text, skills, one-shot Bash, task tools, and every other model-facing plugin. Filesystem policy facts are logged as runtime user context rather than appended to the system prompt. The editor requires absolute paths as an unconditional current contract, so the obsolete `requireAbsolutePath` option is absent. + +## Choose workspace and session IDs + +`cwd` selects the workspace available to the agent, while `session_root` stores session logs and state. Use a fresh session id for an independent task; reuse an id only when the next call should continue the same conversation and persistent shell state. + +The composition uses `danger-full-access`. Run it only inside a disposable checkout or container: Bash and the editor can modify any path allowed to the runtime process. The persistent PTY backend requires a POSIX terminal substrate and is not a Windows agent surface. + +For the complete SDK lifecycle and result contract, see the [Python SDK reference](../../../python/sdk/README.md). For Cordis composition syntax, see [Configuration](./config.md). diff --git a/docs/user/guide/python-sdk.zh.md b/docs/user/guide/python-sdk.zh.md new file mode 100644 index 0000000000..f8fa877b40 --- /dev/null +++ b/docs/user/guide/python-sdk.zh.md @@ -0,0 +1,130 @@ +# Python SDK 快速上手 + +[English](python-sdk.md) | 中文 + +本教程介绍如何安装 Python SDK、在不使用 Web UI 的情况下运行仓库内置 Cordis 组合,以及如何在自己的程序中调用同一套 API。教程使用精简且完整的 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 作为示例,其中固定了系统提示词、工具目录、持久 shell 行为和压缩(compaction)策略。 + +## 前置要求 + +- Python 3.10 或更高版本 +- Linux x64、Linux arm64 或 macOS arm64 +- DeepSeek 兼容的 API 端点与凭据 +- agent 可以修改的隔离 workspace + +## 安装 SDK + +可以选择安装公开包或从源码构建。两种方式都会安装 `deepseek-harness-sdk` 分发包,并提供 `deepseek_harness` Python 模块。 + +### 从 PyPI 安装 + +请创建虚拟环境,并安装 SDK 及其同版本内置运行时: + +```sh +python -m venv .venv +. .venv/bin/activate +python -m pip install deepseek-harness-sdk +``` + +### 从源码构建 + +从源码构建还需要 Git、Node.js ^22.19 或 >= 24、通过 Corepack 启用的 pnpm 11,以及 `uv`。以下命令为当前受支持的宿主平台构建运行时和两个 wheel 包,并将它们安装进当前虚拟环境: + +```sh +git clone https://github.com/deepseek-ai/deepseek-harness.git deepseek-harness +cd deepseek-harness +python -m pip install uv==0.11.23 +corepack enable +pnpm install + +case "$(uname -s):$(uname -m)" in + Linux:x86_64) runtime_platform=linux-x64 ;; + Linux:aarch64|Linux:arm64) runtime_platform=linux-arm64 ;; + Darwin:arm64) runtime_platform=macos-arm64 ;; + *) echo "unsupported platform" >&2; exit 1 ;; +esac + +pnpm exec tsx scripts/build-exe-for-python-sdk.ts --targets="node24-$runtime_platform" +version="$(node -p "require('./package.json').version")" +python scripts/build-python-release.py --package sdk --output-dir dist-python +python scripts/build-python-release.py \ + --package runtime \ + --platform "$runtime_platform" \ + --runtime-exe "dist-exe/dsh-jsonrpc-agent-pkg-$runtime_platform" \ + --output-dir dist-python +python -m pip install --find-links dist-python "deepseek-harness-sdk==$version" +``` + +运行时 wheel 包含 JSON-RPC 可执行文件,以及完整 [`minimal.cordis.yml`](../../../examples/jsonrpc-agent/minimal.cordis.yml) 使用的每个插件,因此两种安装方式完成后都不再需要 Node.js。 + +## 运行仓库内置示例 + +请在环境中设置凭据。如果模型不是由默认 DeepSeek 端点提供,而是通过 OpenAI 兼容代理提供,还需要设置 `DEEPSEEK_BASE_URL`。 + +```sh +export DEEPSEEK_API_KEY=sk-your-key-here +# export DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 +``` + +从仓库 checkout 运行一个任务: + +```sh +python examples/jsonrpc-agent/minimal.py \ + --workspace /absolute/path/to/workspace \ + --session-root /absolute/path/to/sessions \ + --session-id example-001 \ + "Inspect the repository and fix the failing tests." +``` + +脚本会打印 assistant 的最终回复。会话根目录会收到 JSONL 会话日志,其中包含组装后的模型请求与每次工具调用。 + +## 在自己的程序中使用 SDK + +该示例是以下 SDK 调用的轻量包装层: + +```python +from pathlib import Path + +from deepseek_harness import DeepSeekHarness + +config = Path("examples/jsonrpc-agent/minimal.cordis.yml").resolve() +workspace = Path("/absolute/path/to/workspace").resolve() +sessions = Path("/absolute/path/to/sessions").resolve() + +with DeepSeekHarness( + provider="deepseek-official", + model="deepseek-v4-flash", + max_tokens=49_152, + cwd=str(workspace), + session_root=str(sessions), + cordis=str(config), +) as harness: + result = harness.run( + "Inspect the repository and fix the failing tests.", + session_id="example-001", + ) + +print(result.final_response) +``` + +`DeepSeekHarness` 会延迟启动内置 JSON-RPC 运行时,并持续复用,直至退出上下文管理器。在多次调用中复用同一个 harness 和 session id,还会保留该会话拥有的 Bash 进程,包括其工作目录、已导出的变量与 shell 函数。 + +## 了解示例配置 + +| 方面 | 固定值 | +|---|---| +| 系统提示词 | `You are a helpful software engineer assistant.` | +| 面向模型的工具 | 仅持久 `bash` 与 `str_replace_editor` | +| Bash 超时 | 300 秒 | +| 编辑器输出上限 | 16,000 个字符 | +| 压缩 | 触发比例 `0.8`、保留 `20,480` 个 token、摘要上限 `8,192` 个 token、重试 1 次 | +| 会话持久化 | `DSH_SESSION_ROOT` 下未压缩的 JSONL | + +该配置省略了 harness 身份、workspace 提示词文本、skill(技能)、一次性 Bash、任务工具和其他所有面向模型的插件。文件系统策略事实记录为运行时用户上下文,而不会追加到系统提示词中。编辑器无条件要求绝对路径,因此配置中没有已经废弃的 `requireAbsolutePath` 选项。 + +## 选择 workspace 与 session id + +`cwd` 用于选择 agent 可访问的 workspace,`session_root` 用于保存会话日志和状态。独立任务应使用新的 session id;只有下一次调用需要延续同一段对话和持久 shell 状态时,才复用原有 id。 + +该组合使用 `danger-full-access`。只能在可丢弃的 checkout 或容器内运行:Bash 与编辑器可以修改运行时进程有权访问的任何路径。持久 PTY 后端需要 POSIX 终端环境,因此该模式不适用于 Windows agent。 + +完整的 SDK 生命周期与结果约定见 [Python SDK 参考](../../../python/sdk/README.md)。Cordis 组合语法见[配置](./config.md)。 diff --git a/docs/user/guide/quickstart.i18n.yaml b/docs/user/guide/quickstart.i18n.yaml index dc9b7cb25b..0cca002d4f 100644 --- a/docs/user/guide/quickstart.i18n.yaml +++ b/docs/user/guide/quickstart.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write docs/user/guide/quickstart.md -quickstart.md: ce196641f205324334533c025b4ac1dc791f857d -quickstart.zh.md: 3a5d6d0748ec0c7ec83c74570d0fad1e8d66a97c +quickstart.md: e93e5a430f0cb345728581cd6fa3175ffd20b7d1 +quickstart.zh.md: 69cde830bb802ef19cc1204685395b957a0e02e3 diff --git a/docs/user/guide/quickstart.md b/docs/user/guide/quickstart.md index ce196641f2..e93e5a430f 100644 --- a/docs/user/guide/quickstart.md +++ b/docs/user/guide/quickstart.md @@ -19,10 +19,9 @@ pnpm -v ## Step 1: install and configure the API key ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install -pnpm run build ``` Create the gitignored repository-root `.env`: @@ -36,27 +35,28 @@ DEEPSEEK_API_KEY=sk-your-key-here Run a non-interactive task and print its final answer: ```sh -pnpm run dsh run "summarize the architecture of this workspace" +pnpm dsh --profile headless "summarize the architecture of this workspace" ``` -`dsh run` creates and persists a fresh session, prints the final assistant answer, and exits. It starts no Web server or listening port, and a successful run leaves stderr empty. +`dsh --profile headless` creates and persists a fresh session, prints the final assistant answer, and exits. It starts no Web server or listening port, and a successful run leaves stderr empty. ## Step 3: use the Web UI Start the browser interface: ```sh -pnpm run dsh web +pnpm dsh web ``` Open `http://127.0.0.1:3080`. The agent can read and write files, run commands, delegate subtasks, and track a plan. Try: `Create hello.js in the current directory, print "Hello from Harness!", and run it`. ## What happened -`dsh run` boots the `headless` profile: [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) and [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) compose over an empty root, then the runner drives the core Agent and Session services directly. `dsh web` instead composes `dsh-base` with [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml), which owns the Host, HTTP, and browser layers. Both read the same default DeepSeek model route from `dsh-base`. +`dsh --profile headless` boots the `headless` profile: [`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) and [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) compose over an empty root, then the runner drives the core Agent and Session services directly. `dsh web` instead composes `dsh-base` with [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml), which owns the Host, HTTP, and browser layers. Both read the same default DeepSeek model route from `dsh-base`. ## Next steps +- [Get started with the Python SDK](./python-sdk.md) — install the SDK and run a complete Cordis configuration without the Web UI - [Configure models](./providers.md) — reach providers beyond DeepSeek, and custom gateways - [Configuration](./config.md) — understand the `cordis.yml` format - [Develop a plugin](../develop/basic/) — build your own tool or backend diff --git a/docs/user/guide/quickstart.zh.md b/docs/user/guide/quickstart.zh.md index 3a5d6d0748..69cde830bb 100644 --- a/docs/user/guide/quickstart.zh.md +++ b/docs/user/guide/quickstart.zh.md @@ -19,10 +19,9 @@ pnpm -v ## 第一步:安装并配置 API 密钥 ```sh -git clone https://github.com/deepseek-ai/deepseek-harness-sdk.git +git clone https://github.com/deepseek-ai/deepseek-harness.git cd deepseek-harness pnpm install -pnpm run build ``` 在仓库根目录创建已被 Git 忽略的 `.env`: @@ -36,27 +35,28 @@ DEEPSEEK_API_KEY=sk-your-key-here 运行一个非交互式任务并打印最终回答: ```sh -pnpm run dsh run "summarize the architecture of this workspace" +pnpm dsh --profile headless "summarize the architecture of this workspace" ``` -`dsh run` 创建并持久化一个新会话,打印最终助手回答,然后退出。它不会启动 Web 服务器或监听端口;成功运行时 stderr 为空。 +`dsh --profile headless` 创建并持久化一个新会话,打印最终助手回答,然后退出。它不会启动 Web 服务器或监听端口;成功运行时 stderr 为空。 ## 第三步:使用 Web UI 启动浏览器界面: ```sh -pnpm run dsh web +pnpm dsh web ``` 打开 `http://127.0.0.1:3080`。agent 可以读写文件、运行命令、分配子任务和跟踪计划。可以尝试:`Create hello.js in the current directory, print "Hello from Harness!", and run it`。 ## 运行原理 -`dsh run` 启动 `headless` profile:[`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) 和 [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) 在空根之上组合,随后 runner 直接驱动 core Agent 与 Session 服务。`dsh web` 则由 `dsh-base` 与 [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 组合,后者拥有 Host、HTTP 与浏览器层。二者都从 `dsh-base` 读取同一个默认 DeepSeek 模型路由。 +`dsh --profile headless` 启动 `headless` profile:[`dsh-base`](../../../packages/bundle/base/cordis.patch.yml) 和 [`dsh-headless`](../../../packages/bundle/headless/cordis.patch.yml) 在空根之上组合,随后 runner 直接驱动 core Agent 与 Session 服务。`dsh web` 则由 `dsh-base` 与 [`dsh-web-app`](../../../packages/bundle/web-app/cordis.patch.yml) 组合,后者拥有 Host、HTTP 与浏览器层。二者都从 `dsh-base` 读取同一个默认 DeepSeek 模型路由。 ## 下一步 +- [Python SDK 快速上手](./python-sdk.md) — 安装 SDK,并在不使用 Web UI 的情况下运行完整 Cordis 配置 - [配置模型](./providers.md) — 接入 DeepSeek 之外的提供方与自定义网关 - [配置文件](./config.md) — 了解 `cordis.yml` 的格式 - [开发插件](../develop/basic/) — 编写自己的工具或后端 diff --git a/examples/acp-agent/advanced.cordis.snapshot.yml b/examples/acp-agent/advanced.cordis.snapshot.yml index c89fdaf2a6..19d4a79fca 100644 --- a/examples/acp-agent/advanced.cordis.snapshot.yml +++ b/examples/acp-agent/advanced.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Replay counterpart to advanced.cordis.yml; only the live model is replaced. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/advanced.cordis.yml b/examples/acp-agent/advanced.cordis.yml index aa20e1558d..1273d4f059 100644 --- a/examples/acp-agent/advanced.cordis.yml +++ b/examples/acp-agent/advanced.cordis.yml @@ -1,7 +1,7 @@ # Add Code Mode and Cordis tools to the base spawn/workflow stack, exercising # all four boundaries in one ACP snapshot. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/both-mode.cordis.snapshot.yml b/examples/acp-agent/both-mode.cordis.snapshot.yml index 84a286649a..8f2c92d62e 100644 --- a/examples/acp-agent/both-mode.cordis.snapshot.yml +++ b/examples/acp-agent/both-mode.cordis.snapshot.yml @@ -2,7 +2,7 @@ # swap. Include patches cannot target entries behind a nested include, so this file # applies both overlays directly to `cordis.yml`. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/both-mode.cordis.yml b/examples/acp-agent/both-mode.cordis.yml index d793e616f9..58e465176c 100644 --- a/examples/acp-agent/both-mode.cordis.yml +++ b/examples/acp-agent/both-mode.cordis.yml @@ -3,7 +3,7 @@ # this overlay for snapshot recording and the sibling overlay for replay. A config # patch replaces the whole app config, so unchanged base fields are restated below. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/child-question.cordis.snapshot.yml b/examples/acp-agent/child-question.cordis.snapshot.yml index 4eb0c5bfb4..d9d87e8db9 100644 --- a/examples/acp-agent/child-question.cordis.snapshot.yml +++ b/examples/acp-agent/child-question.cordis.snapshot.yml @@ -2,7 +2,7 @@ # seam, model-facing tool, and tripwire provider while replacing DeepSeek with # per-session replay. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/child-question.cordis.yml b/examples/acp-agent/child-question.cordis.yml index 803d21c35d..8de7fe135b 100644 --- a/examples/acp-agent/child-question.cordis.yml +++ b/examples/acp-agent/child-question.cordis.yml @@ -1,7 +1,7 @@ # Snapshot-only human-interaction composition. The provider is a tripwire: the # runtime-owned child must be rejected by the seam before any UI wait begins. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml b/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml index 684ac2b27d..650d950e69 100644 --- a/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode-workspace-context.cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless replay counterpart of code-mode-workspace-context.cordis.yml. It adds # Code Mode to the default filesystem suite and swaps in replay. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/code-mode-workspace-context.cordis.yml b/examples/acp-agent/code-mode-workspace-context.cordis.yml index a724a86961..02ca7d40de 100644 --- a/examples/acp-agent/code-mode-workspace-context.cordis.yml +++ b/examples/acp-agent/code-mode-workspace-context.cordis.yml @@ -1,7 +1,7 @@ # Code Mode workspace-context snapshot recording overlay. The default filesystem # tools trigger nested instruction discovery after a read. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/code-mode.cordis.snapshot.yml b/examples/acp-agent/code-mode.cordis.snapshot.yml index 992a442343..7c114d81f3 100644 --- a/examples/acp-agent/code-mode.cordis.snapshot.yml +++ b/examples/acp-agent/code-mode.cordis.snapshot.yml @@ -2,7 +2,7 @@ # swap. Include patches cannot target entries behind a nested include, so this file # applies both overlays directly to `cordis.yml`. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/code-mode.cordis.yml b/examples/acp-agent/code-mode.cordis.yml index ec31a1fc87..8ad70610b9 100644 --- a/examples/acp-agent/code-mode.cordis.yml +++ b/examples/acp-agent/code-mode.cordis.yml @@ -4,7 +4,7 @@ # replay overlay for `DSH_SNAPSHOT=replay`. A config patch replaces the whole app # config, so unchanged base fields are restated below. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/cordis-tools.cordis.yml b/examples/acp-agent/cordis-tools.cordis.yml index c6e3e84457..87ba11858e 100644 --- a/examples/acp-agent/cordis-tools.cordis.yml +++ b/examples/acp-agent/cordis-tools.cordis.yml @@ -1,7 +1,7 @@ # Add the self-referential Cordis tools without changing the base ACP tool # presentation mode. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/cordis.snapshot.yml b/examples/acp-agent/cordis.snapshot.yml index 5bc771a0fc..46eff1e869 100644 --- a/examples/acp-agent/cordis.snapshot.yml +++ b/examples/acp-agent/cordis.snapshot.yml @@ -9,7 +9,7 @@ # `DSH_SNAPSHOT_OVERRIDE` from the harness. The one-shot patch applies at include # load time, and stdout remains reserved for ACP JSON-RPC. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/depth-two.cordis.snapshot.yml b/examples/acp-agent/depth-two.cordis.snapshot.yml index 3e292699d1..e988f9be60 100644 --- a/examples/acp-agent/depth-two.cordis.snapshot.yml +++ b/examples/acp-agent/depth-two.cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless counterpart to depth-two.cordis.yml: apply the depth patch and replace # the live adapter with per-session replay. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/depth-two.cordis.yml b/examples/acp-agent/depth-two.cordis.yml index 1af96e9283..73e02d7ffc 100644 --- a/examples/acp-agent/depth-two.cordis.yml +++ b/examples/acp-agent/depth-two.cordis.yml @@ -1,7 +1,7 @@ # Depth-limit snapshot overlay: keep the default composition and allow two # generations of spawn children before runtime enforcement rejects another. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/fs.cordis.snapshot.yml b/examples/acp-agent/fs.cordis.snapshot.yml index 0cab77bb36..ee922dba07 100644 --- a/examples/acp-agent/fs.cordis.snapshot.yml +++ b/examples/acp-agent/fs.cordis.snapshot.yml @@ -5,7 +5,7 @@ # `deepseek-v4-pro`, but the recorded corpus was captured on flash, and a config # patch replaces the whole app config, so the base fields are restated verbatim. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/fs.cordis.yml b/examples/acp-agent/fs.cordis.yml index 0d667255c8..c68e361302 100644 --- a/examples/acp-agent/fs.cordis.yml +++ b/examples/acp-agent/fs.cordis.yml @@ -2,7 +2,7 @@ # the base cordis.yml, so this overlay adds only the local tool-result spill # storage those scenarios exercise. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/image-text-route.cordis.snapshot.yml b/examples/acp-agent/image-text-route.cordis.snapshot.yml new file mode 100644 index 0000000000..bd1c0e01ea --- /dev/null +++ b/examples/acp-agent/image-text-route.cordis.snapshot.yml @@ -0,0 +1,39 @@ +# Keyless replay for the read-image refusal scenario: identical to the +# image.cordis.snapshot.yml overlay except the replay catalog leaves flash +# text-only, so the strict read_image gate refuses and no image ever enters +# the durable log. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + inputModalities: [text] + - id: deepseek-v4-pro + inputModalities: [text] diff --git a/examples/acp-agent/image-text-route.cordis.yml b/examples/acp-agent/image-text-route.cordis.yml new file mode 100644 index 0000000000..bbb5b9b4c0 --- /dev/null +++ b/examples/acp-agent/image-text-route.cordis.yml @@ -0,0 +1,27 @@ +# Text-route image overlay: the attachment store registers read_image, but the +# strict execution gate refuses on a route that does not declare image input, +# so a text-only deployment keeps its durable history text-clean. The app +# config is restated to re-pin `deepseek-v4-flash` (base ships pro; the +# authored fixture and the pinned header class are flash), because a config +# patch replaces the whole app config. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' diff --git a/examples/acp-agent/image.cordis.snapshot.yml b/examples/acp-agent/image.cordis.snapshot.yml new file mode 100644 index 0000000000..f056f775d3 --- /dev/null +++ b/examples/acp-agent/image.cordis.snapshot.yml @@ -0,0 +1,41 @@ +# Keyless replay for the read-image success scenario. Include patches cannot +# target entries behind a nested include, so this restates the replay overlay +# directly over the base cordis.yml (the fs.cordis.snapshot.yml pattern) and +# re-pins the recorded flash model. The replay catalog declares image input on +# flash, so the strict read_image gate accepts the route and the tool result +# carries the durable image block; the live DeepSeek route cannot record this. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + inputModalities: [text, image] + - id: deepseek-v4-pro + inputModalities: [text] diff --git a/examples/acp-agent/image.cordis.yml b/examples/acp-agent/image.cordis.yml new file mode 100644 index 0000000000..a12cef1e85 --- /dev/null +++ b/examples/acp-agent/image.cordis.yml @@ -0,0 +1,27 @@ +# Image-scenario overlay: adds the durable attachment store the read_image tool +# commits through. The store resolves its root from $DSH_HOME, which the +# snapshot harness scopes per run, so the overlay itself carries no paths. The +# app config is restated to re-pin `deepseek-v4-flash` (base ships pro; the +# authored fixture and the pinned header class are flash), because a config +# patch replaces the whole app config. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - insert: + - id: attachment-local + name: '@deepseek-ai/dsh-attachment-local' diff --git a/examples/acp-agent/partial-landlock.cordis.snapshot.yml b/examples/acp-agent/partial-landlock.cordis.snapshot.yml index af834b885d..ce48d20ac7 100644 --- a/examples/acp-agent/partial-landlock.cordis.snapshot.yml +++ b/examples/acp-agent/partial-landlock.cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless runner-classification composition: replay authored model turns and # replace the shipping provider with a deterministic process-launch stand-in. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/partial-landlock.cordis.yml b/examples/acp-agent/partial-landlock.cordis.yml index 7a958bb6de..2272c657d1 100644 --- a/examples/acp-agent/partial-landlock.cordis.yml +++ b/examples/acp-agent/partial-landlock.cordis.yml @@ -1,7 +1,7 @@ # Live counterpart for the runner-classification snapshot overlay. It replaces # only the sandbox provider; authored scenarios are skipped in record mode. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/product-subagent-both.cordis.snapshot.yml b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml new file mode 100644 index 0000000000..73d22eae27 --- /dev/null +++ b/examples/acp-agent/product-subagent-both.cordis.snapshot.yml @@ -0,0 +1,38 @@ +# Keyless twin of product-subagent-both.cordis.yml: preserve both product +# tools while replacing only the external model adapter. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-both.cordis.yml b/examples/acp-agent/product-subagent-both.cordis.yml new file mode 100644 index 0000000000..ce9d054058 --- /dev/null +++ b/examples/acp-agent/product-subagent-both.cordis.yml @@ -0,0 +1,27 @@ +# Add both native product providers and the same independent foreground tool +# rows an Agent Preset may contribute. Loading the composition starts neither +# product; the scenario pins both model-visible schemas. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed + - id: tool-subagent-claude-code + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: claude-code + toolName: subagent_claude_code + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml new file mode 100644 index 0000000000..8b8cd8604c --- /dev/null +++ b/examples/acp-agent/product-subagent-codex.cordis.snapshot.yml @@ -0,0 +1,29 @@ +# Keyless twin of product-subagent-codex.cordis.yml: keep the same product +# provider/tool composition and replace only the external model adapter. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/product-subagent-codex.cordis.yml b/examples/acp-agent/product-subagent-codex.cordis.yml new file mode 100644 index 0000000000..55f9985b7d --- /dev/null +++ b/examples/acp-agent/product-subagent-codex.cordis.yml @@ -0,0 +1,18 @@ +# Add the native Codex product provider and its preset-shaped foreground tool to +# the real ACP composition. The model is told not to call it; the scenario pins +# the assembled request schema without starting Codex. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + - id: tool-subagent-codex + name: '@deepseek-ai/dsh-tool-subagent' + config: + provider: codex + toolName: subagent_codex + enableRunInBackground: false + maxDepth: provider-managed diff --git a/examples/acp-agent/pty.cordis.snapshot.yml b/examples/acp-agent/pty.cordis.snapshot.yml index c918f74c56..75e9f313b6 100644 --- a/examples/acp-agent/pty.cordis.snapshot.yml +++ b/examples/acp-agent/pty.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless replay counterpart to pty.cordis.yml. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/pty.cordis.yml b/examples/acp-agent/pty.cordis.yml index 5e911b29d0..0ff9c1d4b5 100644 --- a/examples/acp-agent/pty.cordis.yml +++ b/examples/acp-agent/pty.cordis.yml @@ -1,7 +1,7 @@ # Opt-in persistent PTY composition for the PTY snapshot scenario. The base # deployment already owns the shared sandbox provider and policy. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/retry.cordis.snapshot.yml b/examples/acp-agent/retry.cordis.snapshot.yml index 72370f1a70..c69b08fe64 100644 --- a/examples/acp-agent/retry.cordis.snapshot.yml +++ b/examples/acp-agent/retry.cordis.snapshot.yml @@ -3,7 +3,7 @@ # 1 ms zero-jitter retry policy as the live sibling. The app patch still # restates its whole config for raw JSONL persistence and the recorded model. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/retry.cordis.yml b/examples/acp-agent/retry.cordis.yml index 087faa271d..2da66a3e44 100644 --- a/examples/acp-agent/retry.cordis.yml +++ b/examples/acp-agent/retry.cordis.yml @@ -6,7 +6,7 @@ # adapter fields around `retryPolicy`, while the app patch re-pins the recorded # flash model and restates its base fields. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/session-query.cordis.snapshot.yml b/examples/acp-agent/session-query.cordis.snapshot.yml index 1edadf8374..33029a9c7a 100644 --- a/examples/acp-agent/session-query.cordis.snapshot.yml +++ b/examples/acp-agent/session-query.cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless counterpart to session-query.cordis.yml: the nested snapshot overlay # supplies replay plus deterministic private spill storage and its byte limit. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./fs.cordis.snapshot.yml patches: diff --git a/examples/acp-agent/session-query.cordis.yml b/examples/acp-agent/session-query.cordis.yml index e5e45025df..03ed085fa2 100644 --- a/examples/acp-agent/session-query.cordis.yml +++ b/examples/acp-agent/session-query.cordis.yml @@ -1,7 +1,7 @@ # Explicit session-query tool opt-in for the dedicated spill scenario. The # nested filesystem overlay supplies private spill storage and its byte limit. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./fs.cordis.yml patches: diff --git a/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml b/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml index 02b1d303d4..55627c17fa 100644 --- a/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml +++ b/examples/acp-agent/session-sandbox-root.cordis.snapshot.yml @@ -3,7 +3,7 @@ # and the deliberately distinct sandbox fallback are applied together to the # live tree. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/session-sandbox-root.cordis.yml b/examples/acp-agent/session-sandbox-root.cordis.yml index f27732fd68..fd2d712882 100644 --- a/examples/acp-agent/session-sandbox-root.cordis.yml +++ b/examples/acp-agent/session-sandbox-root.cordis.yml @@ -3,7 +3,7 @@ # /tmp. A workspace-write mutation can therefore succeed only when the calling # session's cwd replaces the process-level fallback root. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/session-title.cordis.snapshot.yml b/examples/acp-agent/session-title.cordis.snapshot.yml index b10e82cfcc..4debc76a02 100644 --- a/examples/acp-agent/session-title.cordis.snapshot.yml +++ b/examples/acp-agent/session-title.cordis.snapshot.yml @@ -2,7 +2,7 @@ # the auxiliary route consumes replay.override.json with pacing so its accepted # title commits only after the main turn has closed. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/session-title.cordis.yml b/examples/acp-agent/session-title.cordis.yml index 0819b79d98..e18a6eeafd 100644 --- a/examples/acp-agent/session-title.cordis.yml +++ b/examples/acp-agent/session-title.cordis.yml @@ -2,7 +2,7 @@ # the ordinary DeepSeek route while the ACP app and every other capability stay # identical to the base example. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml b/examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml new file mode 100644 index 0000000000..43822afbc3 --- /dev/null +++ b/examples/acp-agent/subagent-continuable-inheritance.cordis.snapshot.yml @@ -0,0 +1,46 @@ +# Keyless counterpart to subagent-continuable-inheritance.cordis.yml: replace +# the live adapter with replay and switch the root session to read-only at +# creation. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + disabled: true + - id: acp-agent + name: '@deepseek-ai/dsh-acp-demo' + config: + provider: deepseek-official + model: deepseek-v4-flash + persistenceRoot: !!js process.env.DSH_SNAPSHOT_SESSIONS_ROOT ?? './.sessions' + persistenceCompression: none + workspaceContext: + maxBytes: 65536 + persona: | + You are a coding assistant powered by the {{model}} model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + + Verify your work by running the code or tests. Keep answers brief and factual. + - id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + config: + runnerCommand: + - bash + - -c + - while [ "$1" != "--" ]; do shift; done; shift; exec "$@" + - passthrough-runner + runnerFailureSignatures: + - 'passthrough-runner: profile rejected' + - insert: + - id: llm-replay + name: '@deepseek-ai/dsh-llm-replay' + config: + providers: + - id: deepseek-official + name: DeepSeek + models: + - id: deepseek-v4-flash + - id: deepseek-v4-pro + - id: parent-sandbox-override + name: './tests/fixtures/parent-sandbox-override.ts' diff --git a/examples/acp-agent/subagent-continuable-inheritance.cordis.yml b/examples/acp-agent/subagent-continuable-inheritance.cordis.yml new file mode 100644 index 0000000000..227982e4bf --- /dev/null +++ b/examples/acp-agent/subagent-continuable-inheritance.cordis.yml @@ -0,0 +1,11 @@ +# Policy-inheritance overlay: the root session is switched to read-only at +# creation (the UI Access switch equivalent), so a continuable background +# child must inherit that override instead of the deployment default. +- id: base + name: '@deepseek-ai/cordis-plugin-include' + config: + path: ./cordis.yml + patches: + - insert: + - id: parent-sandbox-override + name: './tests/fixtures/parent-sandbox-override.ts' diff --git a/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml b/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml index 7ce0733e53..2fc7c8a369 100644 --- a/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml +++ b/examples/acp-agent/subagent-durability-failure.cordis.snapshot.yml @@ -1,7 +1,7 @@ # Keyless counterpart to subagent-durability-failure.cordis.yml: replace the # live adapter with replay and fail the provider-owned final child checkpoint. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/subagent-durability-failure.cordis.yml b/examples/acp-agent/subagent-durability-failure.cordis.yml index c033c323dc..ff5603093e 100644 --- a/examples/acp-agent/subagent-durability-failure.cordis.yml +++ b/examples/acp-agent/subagent-durability-failure.cordis.yml @@ -1,7 +1,7 @@ # Snapshot-only durability-failure overlay. The child turn's ordinary flush # succeeds; the provider-owned final confirmation fails deterministically. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 2a3de5d6b8..3ef3e71000 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -38,6 +38,8 @@ const WORKSPACE_CONTEXT_CONFIG = fileURLToPath(new URL('../workspace-context.cor const ADVANCED_CONFIG = fileURLToPath(new URL('../advanced.cordis.yml', import.meta.url)) const FS_CONFIG = fileURLToPath(new URL('../fs.cordis.yml', import.meta.url)) const SESSION_QUERY_CONFIG = fileURLToPath(new URL('../session-query.cordis.yml', import.meta.url)) +const IMAGE_CONFIG = fileURLToPath(new URL('../image.cordis.yml', import.meta.url)) +const IMAGE_TEXT_ROUTE_CONFIG = fileURLToPath(new URL('../image-text-route.cordis.yml', import.meta.url)) const PTY_CONFIG = fileURLToPath(new URL('../pty.cordis.yml', import.meta.url)) const DEPTH_TWO_CONFIG = fileURLToPath(new URL('../depth-two.cordis.yml', import.meta.url)) const CHILD_QUESTION_CONFIG = fileURLToPath(new URL('../child-question.cordis.yml', import.meta.url)) @@ -47,11 +49,16 @@ const SESSION_TITLE_CONFIG = fileURLToPath(new URL('../session-title.cordis.yml' const SUBAGENT_DURABILITY_FAILURE_CONFIG = fileURLToPath( new URL('../subagent-durability-failure.cordis.yml', import.meta.url), ) +const SUBAGENT_CONTINUABLE_INHERITANCE_CONFIG = fileURLToPath( + new URL('../subagent-continuable-inheritance.cordis.yml', import.meta.url), +) const LSP_CONFIG = fileURLToPath(new URL('./lsp.cordis.yml', import.meta.url)) const WEB_CONFIG = fileURLToPath(new URL('../web.cordis.yml', import.meta.url)) const FS_SEARCH_CONFIG = fileURLToPath(new URL('./fs-search.cordis.yml', import.meta.url)) const PARTIAL_LANDLOCK_CONFIG = fileURLToPath(new URL('../partial-landlock.cordis.yml', import.meta.url)) const PWSH_CONFIG = fileURLToPath(new URL('./pwsh.cordis.yml', import.meta.url)) +const PRODUCT_SUBAGENT_CODEX_CONFIG = fileURLToPath(new URL('../product-subagent-codex.cordis.yml', import.meta.url)) +const PRODUCT_SUBAGENT_BOTH_CONFIG = fileURLToPath(new URL('../product-subagent-both.cordis.yml', import.meta.url)) const FS_DIFF_BOUND_CONFIG = fileURLToPath(new URL('./fs-diff-bound.cordis.yml', import.meta.url)) const SNAPSHOTS_DIR = join(dirname(fileURLToPath(import.meta.url)), 'snapshots') const PACKED_CHUNKS_SOURCE = 'hook-cc-pretool-deny' @@ -123,6 +130,27 @@ const SCENARIOS: Scenario[] = [ // text-turn is the default header pin and owns the prompt and tool-schema // sidecars reused by alternate classes with identical component sequences. { name: 'text-turn', hasModelTurn: true, recorded: true, pinsHeader: true }, + // Product-subagent scenarios are authored schema-isolation fixtures: they + // reuse the stable text-turn transcript so only Loader-composed headers and + // tool sidecars vary. Model output and usage are not evidence here, so record + // mode must not replace them with live-API output. + { + name: 'product-subagent-codex', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'product-subagent-codex', + configPath: PRODUCT_SUBAGENT_CODEX_CONFIG, + }, + { + name: 'product-subagent-both', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'product-subagent-both', + systemPromptSource: 'product-subagent-codex', + configPath: PRODUCT_SUBAGENT_BOTH_CONFIG, + }, { name: 'session-title-after-turn', hasModelTurn: true, @@ -154,6 +182,30 @@ const SCENARIOS: Scenario[] = [ configPath: SESSION_QUERY_CONFIG, posixOnly: true, }, + // Authored keyless replays through the assembled app: the replay catalog + // declares flash image-capable (success) or text-only (refusal), and the + // real read_image tool executes against the workspace fixture and the real + // attachment store. Both boot the same composed header (the tool registers + // with the attachment store, independent of route), so they share one class. + { + name: 'read-image', + hasModelTurn: true, + recorded: false, + pinsHeader: true, + headerClass: 'image', + // The overlay adds no prompt section (read_image carries no guidance), so + // the composed system prompt is byte-identical to the default class; only + // the tool-schema sidecar is class-specific. + systemPromptSource: 'text-turn', + configPath: IMAGE_CONFIG, + }, + { + name: 'read-image-text-route', + hasModelTurn: true, + recorded: false, + headerClass: 'image', + configPath: IMAGE_TEXT_ROUTE_CONFIG, + }, { name: 'pty-tools', hasModelTurn: true, @@ -315,7 +367,17 @@ const SCENARIOS: Scenario[] = [ // Windows bash process-tree kill is deferred with the Bash execution domain. { name: 'cancel-tool-calls', hasModelTurn: true, recorded: false, overridden: true, posixOnly: true }, { name: 'subagent-spawn', hasModelTurn: true, recorded: true }, + // Keyless authored scenario: the child ends at max-tokens with an empty + // usage-only assistant/message after earlier text and a tool call. The + // parent's tool result must retain that assistant output and stop reason. + { name: 'subagent-max-tokens-partial', hasModelTurn: true, recorded: false }, { name: 'subagent-multi', hasModelTurn: true, recorded: true }, + // Authored keyless replay: one assistant message carries two subagent calls + // and the parent log pins call/call/result/result instead of the serial + // interleaving. The twin delegations must stay identical: replay binds child + // scripts and harvest order nondeterministically across concurrent children + // (XXX(concurrent-subagents) in dsh-llm-replay). + { name: 'subagent-parallel', hasModelTurn: true, recorded: false }, { name: 'subagent-fork', hasModelTurn: true, recorded: true }, { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, // Authored continuable-subagent transcript: a background delegation returns @@ -331,6 +393,18 @@ const SCENARIOS: Scenario[] = [ pinsChildToolSchemas: [1], configPath: SUBAGENT_DURABILITY_FAILURE_CONFIG, }, + // Authored policy-inheritance transcript: the root session is switched to + // read-only at creation (the UI Access switch equivalent), and the + // continuable background child's log carries that override as a + // `sandbox/mode` `source: 'delegation'` event, so the child's runtime + // context states the inherited policy instead of the deployment default. + { + name: 'subagent-continuable-inheritance', + hasModelTurn: true, + recorded: false, + pinsChildToolSchemas: [1], + configPath: SUBAGENT_CONTINUABLE_INHERITANCE_CONFIG, + }, // The in-process child is published before its first follow-up fails. The // foreground tool retains both that run-result failure and an independent // published-handle disposal failure. diff --git a/examples/acp-agent/tests/fixtures/child-question-tripwire.ts b/examples/acp-agent/tests/fixtures/child-question-tripwire.ts index 7eb15ac551..7495e68819 100644 --- a/examples/acp-agent/tests/fixtures/child-question-tripwire.ts +++ b/examples/acp-agent/tests/fixtures/child-question-tripwire.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import '@deepseek-ai/dsh-user-interaction' /** Snapshot-only provider whose invocation means the child guard failed. */ diff --git a/examples/acp-agent/tests/fixtures/parent-sandbox-override.ts b/examples/acp-agent/tests/fixtures/parent-sandbox-override.ts new file mode 100644 index 0000000000..262bd8a579 --- /dev/null +++ b/examples/acp-agent/tests/fixtures/parent-sandbox-override.ts @@ -0,0 +1,19 @@ +import type { Context } from '@deepseek-ai/cordis' +import { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-agent' + +export const name = 'parent-sandbox-override' + +/** + * Snapshot-only overlay: switch each ROOT session to `read-only` at creation — + * the UI "Access" switch equivalent (one runtime `sandbox/mode` event on the + * session log) — so the scenario proves a continuable background child + * inherits the parent's explicit override as a `source: 'delegation'` event + * instead of falling back to the deployment default. + */ +export function apply(ctx: Context): void { + ctx.on('agent/created', ({ agent }) => { + if (agent.session.header.parentSession !== undefined) return + setSandboxMode(agent.session, 'read-only') + }) +} diff --git a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts index d3ffa4e8a7..8f5185026e 100644 --- a/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts +++ b/examples/acp-agent/tests/fixtures/subagent-durability-failure.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' export const name = 'subagent-durability-failure' diff --git a/examples/acp-agent/tests/fixtures/subagent-settlement-marker.ts b/examples/acp-agent/tests/fixtures/subagent-settlement-marker.ts index e81bde95c4..8e4ad0a0e9 100644 --- a/examples/acp-agent/tests/fixtures/subagent-settlement-marker.ts +++ b/examples/acp-agent/tests/fixtures/subagent-settlement-marker.ts @@ -1,6 +1,6 @@ import { writeFileSync } from 'node:fs' import { join } from 'node:path' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-subagent' export const name = 'subagent-settlement-marker' diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts index 9d3857ffc8..f98c007125 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-acp/mock-delegating-llm.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' diff --git a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts index e2dc946dfe..781138d9ff 100644 --- a/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts +++ b/examples/acp-agent/tests/fixtures/subagent/subagent-codex/fixture.ts @@ -1,6 +1,6 @@ /** Parent adapter that fails if the composition-only Loader test starts a turn. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' diff --git a/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts b/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts index 6c1cd46fbf..e1819e430f 100644 --- a/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts +++ b/examples/acp-agent/tests/fixtures/workspace-context-compaction.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml index a216a34cfd..4cc6ba1f3e 100644 --- a/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml +++ b/examples/acp-agent/tests/fs-diff-bound.cordis.snapshot.yml @@ -3,7 +3,7 @@ # entries behind a nested include; the acp-agent restatement keeps the recorded # deepseek-v4-flash model and raw JSONL persistence for the harness's harvest. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ../cordis.yml patches: diff --git a/examples/acp-agent/tests/fs-diff-bound.cordis.yml b/examples/acp-agent/tests/fs-diff-bound.cordis.yml index 9be25cdb25..c82a7ce63a 100644 --- a/examples/acp-agent/tests/fs-diff-bound.cordis.yml +++ b/examples/acp-agent/tests/fs-diff-bound.cordis.yml @@ -5,7 +5,7 @@ # verbatim, and the acp-agent restatement re-pins `deepseek-v4-flash` to match # the recorded corpus and its pinned request headers. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ../cordis.yml patches: diff --git a/examples/acp-agent/tests/lsp.cordis.snapshot.yml b/examples/acp-agent/tests/lsp.cordis.snapshot.yml index dc672376b5..574d8f646f 100644 --- a/examples/acp-agent/tests/lsp.cordis.snapshot.yml +++ b/examples/acp-agent/tests/lsp.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless replay keeps the LSP composition intact and replaces only the model adapter. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ../cordis.yml patches: diff --git a/examples/acp-agent/tests/lsp.cordis.yml b/examples/acp-agent/tests/lsp.cordis.yml index 49c9099d65..b9600d5d09 100644 --- a/examples/acp-agent/tests/lsp.cordis.yml +++ b/examples/acp-agent/tests/lsp.cordis.yml @@ -1,7 +1,7 @@ # Exercise the model-facing LSP tool through the shipped ACP app and Loader entry path. # The scenario workspace supplies the deterministic stdio server used by this test composition. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ../cordis.yml patches: diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 7908f0e71b..4abda00a44 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,19 +1,20 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498801881,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"}]}} -{"type":"turn/start","seq":1,"time":1785821418076,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821418076,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821418091,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} -{"type":"step/start","seq":4,"time":1785730458555,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730458555,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730458555,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"f9a2d1b6-8f23-43a5-8702-d413fed40990"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730458555,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730458555,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730458555,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":12,"time":1785498801905,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":13,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":14,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":1785730458561,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9c977ca-2c1a-4a5e-8397-e0b9381a9943"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1785730458561,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":17,"time":1785730458561,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357538290,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357538290,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"}]}} +{"type":"turn/start","seq":2,"time":1786357538290,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357538290,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357538308,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"step/start","seq":5,"time":1786357538310,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730458555,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"ebe0cfa0-a909-47e0-8294-28ad84a8fe77"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357538310,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"b8a6a626-fd2c-4602-a8d4-8074f229bfb5"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357538310,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730458555,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730458555,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":13,"time":1785498801905,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":14,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":15,"time":1785730458561,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":16,"time":1785730458561,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b9c977ca-2c1a-4a5e-8397-e0b9381a9943"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730458561,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":18,"time":1785730458561,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index adf877c24b..0a10247d2e 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,19 +1,20 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498802039,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"}]}} -{"type":"turn/start","seq":1,"time":1785821418251,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821418251,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821418270,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} -{"type":"step/start","seq":4,"time":1785730458703,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730458703,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730458703,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"dfbcd587-db47-4c3d-bbe9-8c031b215fc3"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730458703,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730458703,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730458703,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":12,"time":1785498802068,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":13,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":14,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":15,"time":1785730458709,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5c33b525-4844-4272-b6f2-e036356d0e22"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"step/end","seq":16,"time":1785730458709,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":17,"time":1785730458709,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357538450,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357538450,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"}]}} +{"type":"turn/start","seq":2,"time":1786357538450,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357538450,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357538469,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":5,"time":1786357538470,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730458703,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2ac2cc54-9bce-4cfa-a569-a64f51bc30a7"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357538471,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"d327cdff-ad2e-4f9e-9c54-d4448ee11f2a"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357538471,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730458703,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730458703,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":13,"time":1785498802068,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":14,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":15,"time":1785730458709,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":16,"time":1785730458709,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5c33b525-4844-4272-b6f2-e036356d0e22"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"step/end","seq":17,"time":1785730458709,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":18,"time":1785730458709,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl index d6935b6c98..aea9e3107c 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821417919,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498801761,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_ACP_OK."}],"source":{"kind":"user"},"role":"user","id":"6e45782a-31be-4ba7-8c4a-7411a2027e36"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730458430,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9f38e2b8-1d4e-4c90-8896-00aa42307ea7"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730458430,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"06416873-c855-452d-8996-ea5cf45223d1"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730458430,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498801765,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730458431,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md index 2014dc54e3..b613fbac76 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/system-prompt.expected.md @@ -60,7 +60,7 @@ interface ToolArgsMap { /** Exact service key or event name whose original JSDoc to include; valid only with what:"api" or what:"events". */ name?: string; } & Record; - /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ + /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:"api" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:"events"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:"api" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */ cordis_mount: { /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */ code: string; diff --git a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json index 403d3fbc6c..c979e1db87 100644 --- a/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json +++ b/examples/acp-agent/tests/snapshots/advanced-toolchain/tool-schemas.expected.json @@ -72,7 +72,7 @@ }, { "name": "cordis_mount", - "description": "Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.", + "description": "Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.", "parameters": { "type": "object", "properties": { diff --git a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl index 6fa938c18e..5e34f0eb76 100644 --- a/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl +++ b/examples/acp-agent/tests/snapshots/cordis-inspect-jsdoc/session.jsonl @@ -15,7 +15,7 @@ {"type":"assistant/chunk","seq":13,"time":1785730459883,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} {"type":"assistant/message","seq":14,"time":1785730459883,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6b62bed7-113a-4d2e-a6aa-b935a1063ee2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} {"type":"tool/call","seq":15,"time":1785730459883,"data":{"turn":1,"step":1,"callId":"inspect-tools-api","name":"cordis_inspect","arguments":"{\"what\":\"api\",\"name\":\"tools\"}"}} -{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present this agent's tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent's model sees.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"d422878c-c566-461b-9b6b-a61d241022ea"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":16,"time":1785730459904,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"inspect-tools-api"},"content":[{"type":"tool-result","toolCallId":"inspect-tools-api","content":[{"type":"text","text":"## api\n- tools — Tool registry and execution pipeline.\n /**\n * Present the calling scope's tools in `mode` instead of the deployment\n * default. Nearest scope on the chain wins, so a preset's standing\n * declaration covers every agent joined under it.\n *\n * Scoped only, and one declaration per scope: this is how an agent preset\n * composes Code Mode agents beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation the covered agents' models see.\n * @returns the exact disposer that restores the deployment default.\n */\n presentAs(mode: ToolPresentationMode): () => void\n /**\n * Register globally or in the calling agent scope. Scoped tools shadow\n * globals; duplicates within one layer and the reserved `run_code` name fail.\n * @param definition - tool schema, execution, and optional finalization/presentation callbacks.\n * @returns the exact disposer that unregisters the tool.\n */\n register(definition: ToolDefinition): () => void\n /**\n * Restrict global tools for the calling agent scope. Empty filters, unknown\n * names, scope-local names, and reserved transport names fail. Restrictions\n * intersect; scoped registrations remain visible.\n * @param filter - global-surface mask: `allow` (keep only) and/or `deny` (remove).\n * @returns the exact disposer that lifts this restriction.\n */\n restrict(filter: ToolRestriction): () => void\n /**\n * Register a monotonic guard after the extensible `tools/pre-execute`\n * waterfall. A plain-context guard applies globally; one registered through\n * `agent.ctx` applies only to that agent. Any matching guard may deny by\n * returning a reason, while no guard can force-allow a call another guard\n * denied. The exact effect disposer is returned for ordered ownership and\n * HMR cleanup.\n * @param guard - synchronous check; a returned string denies the execution.\n * @returns the exact disposer that unregisters the guard.\n */\n guard(guard: ToolGuard): () => void\n /**\n * Look up a tool as one scope sees it (scoped\n * shadows global; a restricted-away global reads as absent). Presenters pass\n * the calling agent so the rendered card matches the definition that\n * actually executed.\n * @param name - the tool name as registered.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns the definition the scope resolves, or undefined when none is visible.\n */\n get(name: string, scope?: ScopeKey): ToolDefinition | undefined\n /**\n * Project visible definitions onto the allowlisted model-facing schema fields,\n * excluding execution and presentation callbacks.\n * @param scope - the viewing scope (the agent); omitted = the global view.\n * @returns one deep-cloned schema per visible tool.\n */\n schemas(scope?: ScopeKey): ToolSchema[]\n /**\n * Classify a pending call through the caller's visible tool definition. Only\n * an exact `true` is parallel; unknown, hidden, undeclared, invalid, or\n * throwing classifiers are exclusive.\n * @param exec - call name, parsed arguments, and optional agent scope.\n * @returns the fail-closed scheduling mode.\n */\n executionMode(exec: ToolExecutionInput): ToolExecutionMode\n /**\n * Execute through pre-policy, guards, around-dispatch, post-policy,\n * definition-owned content finalization, and final notification. Tool and\n * listener failures resolve as materialized error results; an invisible tool\n * reports `UNKNOWN_TOOL`. The returned outcome is the same lossless, frozen\n * snapshot final observers receive. Cancellation\n * arriving after entry and before final result materialization skips a\n * not-yet-started body with `ABORTED_BEFORE_DISPATCH` or replaces a\n * successful started outcome with `ABORTED`; already-started work is still\n * drained and may retain a tool-owned structured error.\n * @param exec - the typed same-process call input. The registry assigns its\n * correlation token before policy begins.\n * @returns the materialized final result.\n */\n async execute(exec: ToolExecutionInput): Promise\ntype shapes (referenced by the signatures above — read these before assuming a field is a string):\n export interface Agent {\n readonly id: SessionId;\n readonly options: AgentOptions;\n readonly session: Session;\n readonly inbox: Inbox;\n readonly status: AgentStatus;\n readonly ctx: Context;\n cancel(cause: AgentCancelCause, options?: CancelOptions): void;\n whenIdle(): Promise;\n runMaintenance(task: (signal: AbortSignal) => Promise): Promise;\n send(message: UserMessage, target: InboxTarget, wakeup: boolean): void;\n followup(message: UserMessage): void;\n steer(message: UserMessage): void;\n inject(message: UserMessage): void;\n }\n export type AgentCancelCause = {\n readonly kind: 'user';\n } | {\n readonly kind: 'parent';\n } | {\n readonly kind: 'hook';\n readonly reason: string;\n } | {\n readonly kind: 'disposed';\n };\n export interface AgentOptions {\n provider?: string;\n model?: string;\n maxTokens?: number;\n }\n export type AgentStatus = 'idle' | 'running';\n export interface AssistantMessage extends Message {\n readonly role: 'assistant';\n readonly source: ModelMessageSource;\n }\n export interface AssistantProvenance {\n provider: string;\n model: string;\n replayState?: unknown;\n }\n export type AttachmentId = Branded<'AttachmentId'>;\n export type Branded = string & {\n readonly [BRAND]: B;\n };\n export type CallId = Branded<'CallId'>;\n export interface CancelOptions {\n keepInbox?: boolean | undefined;\n }\n export interface ContentBlockMap {\n 'text': TextBlock;\n 'reasoning': ReasoningBlock;\n 'image': ImageBlock;\n 'tool-call': ToolCallBlock;\n 'tool-result': ToolResultBlock;\n }\n export type ContentBlockType = keyof ContentBlockMap;\n export type ContextFormed = {\n readonly form?: never;\n } | {\n readonly form: 'instructions';\n } | {\n readonly form: 'catalog';\n } | {\n readonly form: 'snapshot';\n readonly sections: readonly ContextSnapshotSection[];\n } | {\n readonly form: 'notice';\n readonly summary: string;\n } | {\n readonly form: 'relay';\n } | {\n readonly form: 'recall';\n };\n export interface ContextSnapshotSection {\n readonly name: string;\n readonly text: string;\n }\n export interface DiffCallView {\n card: 'diff';\n title: string;\n diffs: FileDiff[];\n locations?: FileLocation[];\n }\n export interface DiffResultView {\n card: 'diff';\n title?: string;\n diffs: FileDiff[];\n }\n export interface EpochHeader {\n config: LlmCallConfig;\n adapterDefaults?: LlmCallConfigAdapterDefaults;\n system?: string;\n tools?: ToolSchema[];\n }\n export interface FileDiff {\n path: string;\n oldText: string | null;\n newText: string;\n }\n export interface FileLocation {\n path: string;\n line?: number;\n }\n export type FinishReason = FinishReasonMap[keyof FinishReasonMap];\n export interface FinishReasonMap {\n 'stop': {\n kind: 'stop';\n };\n 'tool-calls': {\n kind: 'tool-calls';\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n 'aborted': {\n kind: 'aborted';\n failure: LlmFailure;\n };\n 'error': {\n kind: 'error';\n failure: LlmFailure;\n };\n }\n export interface GenericCallView {\n card: 'generic';\n title: string;\n kind?: ToolCallKind;\n rawInput?: unknown;\n content?: ContentBlock[];\n locations?: FileLocation[];\n }\n export interface GenericResultView {\n card: 'generic';\n title?: string;\n content?: ContentBlock[];\n }\n export interface ImageAttachmentRef {\n attachmentId: AttachmentId;\n mediaType: ImageMediaType;\n bytes: number;\n width: number;\n height: number;\n name?: string;\n }\n export interface ImageBlock {\n type: 'image';\n attachment: ImageAttachmentRef;\n }\n export type ImageMediaType = 'image/png' | 'image/jpeg' | 'image/webp' | 'image/gif';\n export class Inbox {\n constructor(private readonly session: Session, private readonly notifications: InboxNotifications);\n get nextTurn(): readonly UserMessage[];\n get nextStep(): readonly UserMessage[];\n get hasPending(): boolean;\n clear(): void;\n claim(target: InboxTarget, turn: number): UserMessage[];\n append(target: InboxTarget, message: UserMessage): void;\n prepend(target: InboxTarget, message: UserMessage): void;\n replace(messageId: MessageId, newMessage: UserMessage): boolean;\n remove(messageId: MessageId): boolean;\n splice(target: InboxTarget, start: number, deleteCount: number, inserted: UserMessage[]): UserMessage[];\n }\n export interface InboxNotifications {\n inserted(message: UserMessage): void;\n discarded(message: UserMessage): void;\n claimed(message: UserMessage, turn: number): void;\n }\n export type InboxTarget = 'next-turn' | 'next-step';\n export interface JsonSchemaNode {\n type?: JsonSchemaType;\n oneOf?: JsonSchemaNode[];\n properties?: Record;\n required?: string[];\n additionalProperties?: boolean;\n items?: JsonSchemaNode;\n enum?: JsonSchemaScalar[];\n const?: JsonSchemaScalar;\n description?: string;\n title?: string;\n default?: JsonValue;\n examples?: JsonValue;\n }\n export type JsonSchemaScalar = string | number | boolean | null;\n export type JsonSchemaType = 'object' | 'array' | 'string' | 'number' | 'integer' | 'boolean' | 'null';\n export type JsonValue = null | boolean | number | string | JsonValue[] | {\n [key: string]: JsonValue;\n };\n export interface LlmCallConfig {\n provider: string;\n model: string;\n reasoningEffort?: ReasoningEffortId;\n temperature?: number;\n maxTokens?: number;\n stop?: string[];\n }\n export interface LlmCallConfigAdapterDefaults {\n reasoningEffort?: true;\n maxTokens?: true;\n }\n export interface LlmFailure {\n readonly message: string;\n readonly code: string;\n readonly status?: number;\n readonly providerRetryAfterMs?: number;\n readonly requestId?: ProviderRequestId;\n }\n export interface Message {\n readonly id: MessageId;\n readonly role: 'system' | 'user' | 'assistant';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n }\n export type MessageId = Branded<'MessageId'>;\n export type MessageSource = MessageSourceMap[keyof MessageSourceMap];\n export interface MessageSourceMap {\n user: {\n kind: 'user';\n };\n plugin: {\n kind: 'plugin';\n plugin: string;\n } & ContextFormed;\n model: ModelMessageSource;\n tool: ToolMessageSource;\n }\n export interface ModelMessageSource extends AssistantProvenance {\n kind: 'model';\n }\n export type ProviderRequestId = Branded<'ProviderRequestId'>;\n export interface ReadFileLine {\n number: number;\n text: string;\n }\n export interface ReadResultView {\n card: 'read';\n title?: string;\n path: string;\n offset: number;\n lines: ReadFileLine[];\n totalLines: number;\n lang?: string;\n content?: ContentBlock[];\n }\n export interface ReasoningBlock {\n type: 'reasoning';\n text: string;\n }\n export type ReasoningEffortId = Branded<'ReasoningEffortId'>;\n export interface RequestContext {\n provider: string;\n model: string;\n contextWindow?: number;\n }\n export type RequestHeaderReason = 'initial' | 'resume' | 'change';\n export type ScopeKey = object;\n export interface SearchFileMatches {\n path: string;\n matches: SearchLineMatch[];\n }\n export interface SearchLineMatch {\n lineNumber: number;\n line: string;\n }\n export interface SearchMatchesResultView {\n card: 'search';\n shape: 'matches';\n title?: string;\n files: SearchFileMatches[];\n truncated: boolean;\n total: number;\n }\n export interface SearchPathsResultView {\n card: 'search';\n shape: 'paths';\n title?: string;\n paths: string[];\n truncated: boolean;\n total: number;\n }\n export type SearchResultView = SearchMatchesResultView | SearchPathsResultView;\n export class Session {\n get surface(): SessionSurface;\n readonly header: SessionHeader;\n get id(): SessionId;\n readonly firstLiveSeq: number;\n static create(id: SessionId, seed?: readonly SessionEvent[], header?: SessionHeader): Session;\n static fromRestore(id: SessionId, seed: readonly SessionEvent[], header: SessionHeader): Session;\n get events(): readonly SessionEvent[];\n get seq(): number;\n append(type: T, data: SessionEventMap[T], ...opts: T extends SurfaceEventType ? [\n opts: SurfaceIntent\n ] : [\n ]): SessionEvent;\n requestHeader(): EpochHeader | undefined;\n requestContext(): RequestContext | undefined;\n deriveMessages(): Message[];\n deriveEventMessage(event: SessionEvent): Message | null;\n }\n export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n ignorable?: true;\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n }[T];\n export interface SessionEventMap {\n 'turn/start': {\n turn: number;\n };\n 'turn/end': {\n turn: number;\n reason: TurnEndReason;\n };\n 'step/start': {\n turn: number;\n step: number;\n };\n 'step/end': {\n turn: number;\n step: number;\n };\n 'user/message': UserMessage;\n 'assistant/chunk': {\n turn: number;\n step: number;\n chunk: StreamChunk;\n };\n 'assistant/message': {\n turn: number;\n step: number;\n message: AssistantMessage;\n usage?: TokenUsage;\n };\n 'tool/call': {\n turn: number;\n step: number;\n callId: CallId;\n name: string;\n arguments: string;\n };\n 'tool/result': {\n turn: number;\n step: number;\n message: ToolResultMessage;\n error?: {\n name: string;\n code: string;\n };\n meta?: JsonValue;\n };\n 'todo/write': {\n todos: TodoItem[];\n };\n 'request/header': {\n header: EpochHeader;\n reason: RequestHeaderReason;\n };\n 'request/context': RequestContext;\n 'session/end-seed': Record;\n }\n export type SessionEventType = keyof SessionEventMap;\n export interface SessionHeader {\n readonly version: number;\n readonly id: SessionId;\n readonly createdAt: number;\n readonly cwd?: string;\n readonly parentSession?: SessionId;\n readonly seedLength?: number;\n readonly origin?: 'subagent';\n readonly delegationDepth?: number;\n readonly agentPreset?: string;\n }\n export type SessionId = Branded<'SessionId'>;\n export interface SessionSurface {\n readonly nodes: readonly number[];\n readonly replaceGeneration: number;\n }\n export type StreamChunk = {\n type: 'block-start';\n index: number;\n blockType: ContentBlockType;\n } | {\n type: 'text-delta';\n index: number;\n text: string;\n } | {\n type: 'reasoning-delta';\n index: number;\n text: string;\n } | {\n type: 'tool-call-delta';\n index: number;\n id: CallId;\n name?: string;\n argumentsDelta: string;\n } | {\n type: 'block-end';\n index: number;\n block: ContentBlock;\n } | {\n type: 'usage';\n usage: TokenUsage;\n } | {\n type: 'finish';\n reason: FinishReason;\n replayState?: unknown;\n };\n export type SurfaceEventType = 'user/message' | 'assistant/message' | 'tool/result';\n export interface SurfaceIntent {\n surfaceOp: SurfaceOp;\n sourceEventSeqs?: number[];\n }\n export type SurfaceOp = 'append' | {\n op: 'replace';\n start: number;\n end: number;\n };\n export interface TerminalCallView {\n card: 'terminal';\n title: string;\n description?: string;\n cwd?: string;\n }\n export interface TerminalResultView {\n card: 'terminal';\n title?: string;\n output?: string;\n exitCode?: number;\n signal?: string;\n }\n export interface TodoItem {\n content: string;\n status: 'pending' | 'in_progress' | 'completed';\n }\n export interface TokenUsage {\n inputTokens: number;\n outputTokens: number;\n cacheReadTokens?: number;\n cacheWriteTokens?: number;\n reasoningTokens?: number;\n }\n export interface ToolCallBlock {\n type: 'tool-call';\n id: CallId;\n name: string;\n arguments: string;\n }\n export type ToolCallKind = 'read' | 'edit' | 'delete' | 'move' | 'search' | 'execute' | 'fetch' | 'other';\n export type ToolCallView = GenericCallView | TerminalCallView | DiffCallView;\n export interface ToolDefinition extends ToolSchema {\n readonly output: ToolOutputDefinition;\n execute(args: unknown, exec: ToolRunContext): Promise;\n finalizeContent?(exec: Readonly, result: Readonly): ContentBlock[] | undefined;\n timeoutMs?: number;\n isConcurrencySafe?(args: unknown): boolean;\n presentCall?(args: unknown): ToolCallView | undefined;\n presentResult?(args: unknown, result: ToolResult): ToolResultView | undefined;\n }\n export interface ToolErrorInfo {\n name: string;\n code: string;\n }\n export interface ToolExecution extends ToolExecutionInput {\n readonly rootCallId: CallId;\n readonly token: ToolExecutionToken;\n }\n export interface ToolExecutionFailure {\n readonly isError: true;\n readonly error: ToolFailure;\n readonly value?: never;\n readonly content: ContentBlock[];\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: never;\n }\n export interface ToolExecutionInput {\n readonly callId: CallId;\n readonly rootCallId?: CallId;\n readonly name: string;\n readonly arguments: unknown;\n readonly agent?: Agent;\n readonly parent?: ToolExecutionToken;\n readonly signal: AbortSignal;\n }\n export type ToolExecutionMode = {\n kind: 'parallel';\n } | {\n kind: 'exclusive';\n };\n export type ToolExecutionResult = ToolExecutionSuccess | ToolExecutionFailure;\n export interface ToolExecutionSuccess {\n readonly isError: false;\n readonly value: JsonValue;\n readonly content: ContentBlock[];\n readonly error?: never;\n readonly meta?: JsonValue;\n readonly additionalContexts?: UserMessage[];\n readonly concludesTurn?: true;\n }\n export type ToolExecutionToken = symbol & {\n readonly [toolExecutionTokenBrand]: true;\n };\n export interface ToolFailure {\n message: string;\n info?: ToolErrorInfo;\n }\n export type ToolGuard = (execution: Readonly) => string | undefined;\n export interface ToolMessageSource {\n kind: 'tool';\n callId: CallId;\n }\n export interface ToolOutputDefinition {\n readonly schema: JsonSchemaNode;\n render(args: unknown, value: JsonValue): ContentBlock[];\n presentationMeta?(args: unknown, value: JsonValue): JsonValue;\n }\n export type ToolPresentationMode = 'native' | 'code' | 'both';\n export interface ToolRestriction {\n readonly allow?: readonly string[];\n readonly deny?: readonly string[];\n }\n export interface ToolResult {\n content: ContentBlock[];\n isError: boolean;\n meta?: JsonValue;\n }\n export interface ToolResultBlock {\n type: 'tool-result';\n toolCallId: CallId;\n content: ContentBlock[];\n isError?: boolean;\n }\n export interface ToolResultMessage extends Message {\n readonly role: 'user';\n readonly content: [\n ToolResultBlock\n ];\n readonly source: ToolMessageSource;\n }\n export type ToolResultView = GenericResultView | TerminalResultView | DiffResultView | SearchResultView | ReadResultView | WebResultView;\n export interface ToolRunContext extends ToolExecution {\n deferContext(context: UserMessage): void;\n concludeTurn(): void;\n }\n export interface ToolSchema {\n name: string;\n description: string;\n parameters: Record;\n }\n export type TurnEndCancelCause = AgentCancelCause | {\n readonly kind: 'legacy';\n };\n export type TurnEndReason = TurnEndReasonMap[keyof TurnEndReasonMap];\n export interface TurnEndReasonMap {\n completed: {\n kind: 'completed';\n };\n aborted: {\n kind: 'aborted';\n reason: TurnEndCancelCause;\n };\n blocked: {\n kind: 'blocked';\n };\n error: {\n kind: 'error';\n error: LlmFailure;\n };\n 'max-tokens': {\n kind: 'max-tokens';\n };\n interrupted: {\n kind: 'interrupted';\n };\n }\n export interface UserMessage extends Message {\n readonly role: 'user';\n }\n export interface WebFetchResultView {\n card: 'web';\n kind: 'fetch';\n title?: string;\n url: string;\n statusCode: number;\n truncated: boolean;\n }\n export type WebResultView = WebSearchResultView | WebFetchResultView;\n export interface WebSearchResultView {\n card: 'web';\n kind: 'search';\n title?: string;\n sources: WebSource[];\n answer?: string;\n truncated: boolean;\n }\n export interface WebSource {\n url: string;\n title?: string;\n snippet?: string;\n publishedAt?: string;\n }"}],"isError":false}],"role":"user","id":"eb20999e-deb2-4abe-8517-14de8a6ca238"}},"sourceEventSeqs":[15],"surfaceOp":"append"} {"type":"step/end","seq":17,"time":1785730459904,"data":{"turn":1,"step":1}} {"type":"step/start","seq":18,"time":1785730459916,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":19,"time":1784449176734,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/input.json b/examples/acp-agent/tests/snapshots/product-subagent-both/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl new file mode 100644 index 0000000000..84ac27e70e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/session.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"}]}} +{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"f1418376-f303-4017-acd7-92899c841c8a"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730415298,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730415298,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json new file mode 100644 index 0000000000..76f60e28d4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-both/tool-schemas.expected.json @@ -0,0 +1,569 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_claude_code", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_codex", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/input.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/input.json new file mode 100644 index 0000000000..5fe0259a4e --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Reply with exactly the word: PONG. Do not use any tools." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl new file mode 100644 index 0000000000..bd47dfa24f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/session.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":0,"id":"539aa64c-7f37-40ff-abd8-ed45b717be1b","createdAt":1783600629539,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1785498761270,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"}]}} +{"type":"turn/start","seq":1,"time":1785821359466,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1785821359466,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783600629542,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1785498761313,"data":{"content":[{"type":"text","text":"Reply with exactly the word: PONG. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3e25dc34-48e0-4738-8401-1a8d181d37e5"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730415287,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4b8d9730-0b7b-4e14-8a30-3d852f808f0e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1785730415287,"data":{"title":"Reply with exactly the word:","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1785498761318,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-pro"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1785730415288,"data":{"provider":"deepseek-official","model":"deepseek-v4-pro"}} +{"type":"assistant/chunk","seq":9,"time":1783600630822,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":10,"time0":1783600630852,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,33,1,40,0,0,0,0,0,18,0,36,0,0,0,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","P","ONG","\""," and"," not"," use"," any"," tools","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600631006,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783600631008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"P"}}} +{"type":"assistant/chunk","seq":32,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ONG"}}} +{"type":"assistant/chunk","seq":33,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."}}}} +{"type":"assistant/chunk","seq":34,"time":1783600631009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"PONG"}}}} +{"type":"assistant/chunk","seq":35,"time":1785498761338,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}}}} +{"type":"assistant/chunk","seq":36,"time":1785730415297,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730415298,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"PONG\" and not use any tools."},{"type":"text","text":"PONG"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-pro"},"id":"c883cf16-01fe-4afc-b37c-d255bb450d21"},"usage":{"inputTokens":3091,"outputTokens":23,"cacheReadTokens":0,"reasoningTokens":20}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730415298,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730415298,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl new file mode 100644 index 0000000000..acfccdd778 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PONG"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md b/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md new file mode 100644 index 0000000000..a6ffe7d4d7 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/system-prompt.expected.md @@ -0,0 +1,22 @@ +You are an AI agent powered by the DeepSeek Harness SDK. + +You are a coding assistant powered by the deepseek-v4-pro model. Your working directory is {{cwd}}. Your bash tool runs under a file sandbox — a `[sandbox: file access denied …]` result is policy, not a command bug. + +Verify your work by running the code or tests. Keep answers brief and factual. + + +Use the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files. + +Use the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes. + +Use the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session. + +Check the [exit code: N] marker on every bash result; investigate failures before moving on. + +Track every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering. + +Use goal tools for one long-running completion objective in the current session. create_goal may infer goal intent from a direct human request in any language; do not create a goal for routine single-turn work. Call get_goal before update_goal and copy its exact goal_id and revision. After session resume or fork, an active goal is disarmed: when a human asks to continue or resume in any wording or language, use update_goal action resume to rearm it. Mark complete only when the objective is actually achieved. Mark blocked only after the same blocking condition persists for at least 3 consecutive goal rounds, and report that concrete condition in blocked_reason; difficulty, uncertainty, or useful remaining work is not blocked. + +Use the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls. + +Use the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out. diff --git a/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json new file mode 100644 index 0000000000..84c4671579 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/product-subagent-codex/tool-schemas.expected.json @@ -0,0 +1,548 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_codex", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/read-image-text-route/input.json b/examples/acp-agent/tests/snapshots/read-image-text-route/input.json new file mode 100644 index 0000000000..551bfee00a --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-text-route/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Use read_image on red.png in the current directory. If the tool refuses because the current model is text-only, reply with exactly the single word UNAVAILABLE." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/read-image-text-route/session.jsonl b/examples/acp-agent/tests/snapshots/read-image-text-route/session.jsonl new file mode 100644 index 0000000000..3e32a50a32 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-text-route/session.jsonl @@ -0,0 +1,26 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1783951000001,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image on red.png in the current directory. If the tool refuses because the current model is text-only, reply with exactly the single word UNAVAILABLE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}} +{"type":"turn/start","seq":1,"time":1783951000002,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1783951000002,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783951000003,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image on red.png in the current directory. If the tool refuses because the current model is text-only, reply with exactly the single word UNAVAILABLE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786344284632,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"11a08f07-014a-408b-bfc5-634770ce7179"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786344284632,"data":{"title":"Use read_image on red.png in","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786344284632,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786344284633,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786344284637,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1786344284637,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":1786344284637,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1786344284638,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"9676ac40-f7a8-4a7b-9326-a45fef18f11e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1786344284638,"data":{"turn":1,"step":1,"callId":"read-image-refused","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}} +{"type":"tool/result","seq":15,"time":1786344284643,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-refused"},"content":[{"type":"tool-result","toolCallId":"read-image-refused","content":[{"type":"text","text":"Error: cannot read \"red.png\" as an image: model \"deepseek-v4-flash\" does not declare image input; switch to an image-capable model to read images"}],"isError":true}],"role":"user","id":"ee31751e-df5a-458e-8497-8113cf6107ef"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1786344284643,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1786344284648,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1783951000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":1786344284652,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNAVAILABLE"}}}} +{"type":"assistant/chunk","seq":20,"time":1786344284652,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":21,"time":1786344284652,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":22,"time":1786344284652,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNAVAILABLE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1c15b391-a95a-4113-9d47-2a1dfc991cf9"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1786344284653,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":24,"time":1786344284653,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image-text-route/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image-text-route/stdout.expected.jsonl new file mode 100644 index 0000000000..f93f99ce97 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image-text-route/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"UNAVAILABLE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/read-image-text-route/workspace/red.png b/examples/acp-agent/tests/snapshots/read-image-text-route/workspace/red.png new file mode 100644 index 0000000000..62a5f8f47f Binary files /dev/null and b/examples/acp-agent/tests/snapshots/read-image-text-route/workspace/red.png differ diff --git a/examples/acp-agent/tests/snapshots/read-image/input.json b/examples/acp-agent/tests/snapshots/read-image/input.json new file mode 100644 index 0000000000..603d2ad4ac --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/read-image/session.jsonl b/examples/acp-agent/tests/snapshots/read-image/session.jsonl new file mode 100644 index 0000000000..6acde5a0ff --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image/session.jsonl @@ -0,0 +1,26 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783951000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1783951000001,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"}]}} +{"type":"turn/start","seq":1,"time":1783951000002,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1783951000002,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1783951000003,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1783951000003,"data":{"content":[{"type":"text","text":"Use read_image to look at red.png in the current directory, then reply with exactly the single word DONE."}],"source":{"kind":"user"},"role":"user","id":"0a0a0a0a-0000-4000-8000-000000000001"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786344283033,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"eecd1df6-153c-4a34-b198-42bfc9f9701e"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786344283033,"data":{"title":"Use read_image to look at","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786344283034,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786344283034,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1783951000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":1786344283039,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1786344283039,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"41e9fb55-6edb-419d-b76c-554daa5a1c5d"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1786344283039,"data":{"turn":1,"step":1,"callId":"read-image-call","name":"read_image","arguments":"{\"file_path\":\"red.png\"}"}} +{"type":"tool/result","seq":15,"time":1786344283069,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"read-image-call"},"content":[{"type":"tool-result","toolCallId":"read-image-call","content":[{"type":"text","text":"{{cwd}}/red.png\nimage\n\nimage/png image, 1x1 px, 69 bytes\n"},{"type":"image","attachment":{"attachmentId":"sha256:b1ff9c8ea3a780bad09b346c423d2d0e46815926879b18e841d928376a946640","mediaType":"image/png","bytes":69,"width":1,"height":1,"name":"red.png"}}],"isError":false}],"role":"user","id":"0b5779fc-523e-4275-9a32-8eb5e39f521e"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1786344283069,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1786344283075,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1783951000016,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":1786344283078,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":20,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":21,"time":1786344283079,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":22,"time":1786344283079,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"73a87a50-8e0b-42af-8c54-d9b6fbe375f1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1786344283079,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":24,"time":1786344283079,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json b/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json new file mode 100644 index 0000000000..8da6396169 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json @@ -0,0 +1,543 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "read_image", + "description": "Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to the image file, resolved by the filesystem backend." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/read-image/workspace/red.png b/examples/acp-agent/tests/snapshots/read-image/workspace/red.png new file mode 100644 index 0000000000..62a5f8f47f Binary files /dev/null and b/examples/acp-agent/tests/snapshots/read-image/workspace/red.png differ diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl index 68527f63ac..857dea88b0 100644 --- a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.1.jsonl @@ -1,29 +1,30 @@ {"type":"session","version":0,"id":"55555555-5555-4555-8555-555555555555","createdAt":2001,"cwd":"{{cwd}}","parentSession":"44444444-4444-4444-8444-444444444444","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1786173701247,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"}]}} -{"type":"turn/start","seq":1,"time":1786173701247,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1786173701247,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1786173701270,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check deployment question"}} -{"type":"step/start","seq":4,"time":1786173701272,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1786173701272,"data":{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1786173701272,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"fafefa37-7640-4c80-a00a-6a0c3ce46281"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1786173701272,"data":{"title":"Call ask_user_question once to ask","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1786173701272,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1786173701273,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":11,"time":1786173701278,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_child_question","name":"ask_user_question","argumentsDelta":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}} -{"type":"assistant/chunk","seq":12,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}}} -{"type":"assistant/chunk","seq":13,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":14,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":15,"time":1786173701279,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"301e1969-74b2-45d8-a764-604b806f1c01"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"tool/call","seq":16,"time":1786173701279,"data":{"turn":1,"step":1,"callId":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}} -{"type":"tool/result","seq":17,"time":1786173701292,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_child_question"},"content":[{"type":"tool-result","toolCallId":"call_child_question","content":[{"type":"text","text":"Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result"}],"isError":true}],"role":"user","id":"b9fc0a38-47bb-4335-a8e4-c881ed66bbc3"},"error":{"name":"UserInteractionError","code":"DELEGATED_CALLER"}},"sourceEventSeqs":[16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1786173701292,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":19,"time":1786173701309,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":20,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}} -{"type":"assistant/chunk","seq":22,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}}} -{"type":"assistant/chunk","seq":23,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}} -{"type":"assistant/chunk","seq":24,"time":1786173701315,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":25,"time":1786173701315,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f2ada85-5967-4ed8-9e16-eaff2af847b5"},"usage":{"inputTokens":10,"outputTokens":4}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":1786173701315,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":27,"time":1786173701315,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357535138,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357535138,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"}]}} +{"type":"turn/start","seq":2,"time":1786357535138,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357535138,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357535155,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check deployment question"}} +{"type":"step/start","seq":5,"time":1786357535158,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1786173701272,"data":{"content":[{"type":"text","text":"Call ask_user_question once to ask whether deployment should use the CUDA fallback. If the tool returns an error, include the unresolved question verbatim in your final result."}],"source":{"kind":"user"},"role":"user","id":"106c2785-219e-46e8-8386-497ac6a98f68"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357535158,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"d8734c8a-d956-4e3f-8d28-399adf51a203"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357535158,"data":{"title":"Call ask_user_question once to ask","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1786173701272,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1786173701273,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":1786173701278,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_child_question","name":"ask_user_question","argumentsDelta":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}} +{"type":"assistant/chunk","seq":13,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}}}} +{"type":"assistant/chunk","seq":14,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":15,"time":1786173701279,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":16,"time":1786173701279,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"301e1969-74b2-45d8-a764-604b806f1c01"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","seq":17,"time":1786173701279,"data":{"turn":1,"step":1,"callId":"call_child_question","name":"ask_user_question","arguments":"{\"questions\":[{\"id\":\"cuda-fallback\",\"header\":\"Deployment\",\"question\":\"Should deployment use the CUDA fallback?\"}]}"}} +{"type":"tool/result","seq":18,"time":1786173701292,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_child_question"},"content":[{"type":"tool-result","toolCallId":"call_child_question","content":[{"type":"text","text":"Error: human interaction is unavailable while the calling agent is owned by another live agent; include the unresolved question or decision in the child agent's final result"}],"isError":true}],"role":"user","id":"b9fc0a38-47bb-4335-a8e4-c881ed66bbc3"},"error":{"name":"UserInteractionError","code":"DELEGATED_CALLER"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1786173701292,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1786173701309,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}} +{"type":"assistant/chunk","seq":23,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}}}} +{"type":"assistant/chunk","seq":24,"time":1786173701314,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":25,"time":1786173701315,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":26,"time":1786173701315,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"UNRESOLVED: Should deployment use the CUDA fallback?"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f2ada85-5967-4ed8-9e16-eaff2af847b5"},"usage":{"inputTokens":10,"outputTokens":4}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1786173701315,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":28,"time":1786173701315,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl index ed155f158f..92efdfa71a 100644 --- a/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-child-question-rejection/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1786173701175,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1786173701216,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1786173701216,"data":{"content":[{"type":"text","text":"Delegate one question check. Ask the child to call ask_user_question once about the CUDA fallback and return any unresolved question in its final result."}],"source":{"kind":"user"},"role":"user","id":"851bea02-2961-471a-84ec-3b068c451db0"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1786173701217,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"8ef74c46-9e80-475c-9093-0e85ba92e346"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786173701217,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e1f92805-80c9-46b7-94ac-6cdb05d23f86"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1786173701217,"data":{"title":"Delegate one question check. Ask","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1786173701218,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1786173701219,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json new file mode 100644 index 0000000000..183e21b557 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/input.json @@ -0,0 +1,19 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool." + }, + { + "op": "waitForSubagentTurnEnd", + "child": 1, + "minimumTurn": 1 + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl new file mode 100644 index 0000000000..bd9557666f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.1.jsonl @@ -0,0 +1,22 @@ +{"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} +{"type":"subagent/descriptor","seq":0,"time":1786333735890,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} +{"type":"session/end-seed","seq":1,"time":1786333735890,"data":{}} +{"type":"sandbox/mode","seq":2,"time":1786333735890,"data":{"mode":"read-only","source":"delegation"}} +{"type":"approval/policy","seq":3,"time":1786357527742,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":4,"time":1786357527743,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"}]}} +{"type":"turn/start","seq":5,"time":1786357527743,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":6,"time":1786357527743,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":7,"time":1786357527768,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":8,"time":1786333735916,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"42af19c7-e234-4752-93d4-bd9c943c1fe7"},"surfaceOp":"append"} +{"type":"user/message","seq":9,"time":1786357527769,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"1b760052-ffcb-44d2-aae2-fd73d7c444f1"},"surfaceOp":"append"} +{"type":"session/title","seq":10,"time":1786357527769,"data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":11,"time":1786333735916,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":12,"time":1786333735916,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":13,"time":1786333735920,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":14,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":15,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":16,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":17,"time":1786333735921,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":18,"time":1786333735921,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e2b94008-b067-4ca7-a576-6b4a9060cd83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1786333735921,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":20,"time":1786333735921,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl new file mode 100644 index 0000000000..15b1748ea5 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/session.jsonl @@ -0,0 +1,29 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1789000000000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"sandbox/mode","seq":0,"time":1786333735842,"data":{"mode":"read-only"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786333735845,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"d554122c-d857-4de0-aea0-6452f260d032"}]}} +{"type":"turn/start","seq":2,"time":1786333735845,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786333735845,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":4,"time":1786333735878,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":1786333735878,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"d554122c-d857-4de0-aea0-6452f260d032"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786333735878,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"f931abf5-bb3a-44b4-8fe2-2d06e8766184"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786333735878,"data":{"title":"Follow these steps exactly, then","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1786333735879,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1786333735879,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1789000000000,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":11,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_bg_start","name":"subagent","argumentsDelta":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}} +{"type":"assistant/chunk","seq":12,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}}}} +{"type":"assistant/chunk","seq":13,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":14,"time":1786333735884,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":15,"time":1786333735884,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"8ab58a42-e74c-4121-a6ca-63696e592287"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"tool/call","seq":16,"time":1786333735885,"data":{"turn":1,"step":1,"callId":"call_bg_start","name":"subagent","arguments":"{\"description\": \"Reply with CHILD_OK\", \"prompt\": \"Reply with exactly the word CHILD_OK and nothing else.\", \"run_in_background\": true}"}} +{"type":"tool/result","seq":17,"time":1786333735892,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_bg_start"},"content":[{"type":"tool-result","toolCallId":"call_bg_start","content":[{"type":"text","text":"started subagent 33333333-3333-4333-8333-333333333333"}],"isError":false}],"role":"user","id":"3478555e-f0d0-4ec1-a7e4-a15ab24b9ecf"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1786333735892,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":19,"time":1786333735897,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":20,"time":1786333735903,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":21,"time":1786333735903,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DONE"}}} +{"type":"assistant/chunk","seq":22,"time":1786333735903,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DONE"}}}} +{"type":"assistant/chunk","seq":23,"time":1786333735904,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":24,"time":1786333735904,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":25,"time":1786333735904,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4057a08e-b50e-45e7-beb0-c74485f2b7d6"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} +{"type":"step/end","seq":26,"time":1786333735904,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":27,"time":1786333735904,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl new file mode 100644 index 0000000000..82ae8907ca --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json new file mode 100644 index 0000000000..7dd791cf27 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-continuable-inheritance/tool-schemas.1.expected.json @@ -0,0 +1,543 @@ +{ + "initial": [ + { + "name": "bash", + "description": "Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. Attempting a command the sandbox may deny is safe and expected: run it and read the marker rather than assuming the denial. When a command is denied and a wider mode would let it succeed, escalate immediately in the same turn — the one sanctioned exception to a denial: retry the exact same command once with `sandbox_permissions` (the narrowest wider mode that suffices) plus a one-sentence `justification`. Do not detour through chat to ask permission first — the approval prompt raised by that retry is how the user consents. If the session states approval prompts are disabled, there is no exception: a denial is final — do not set `sandbox_permissions`. Never escalate speculatively: ground the request in a real denial — normally the one this command just hit; escalating up front is fine only when this session already denied the same access. A rejected escalation is final for that command — stop and explain, never work around it — but it does not forbid attempting or escalating other commands later.", + "parameters": { + "type": "object", + "properties": { + "command": { + "type": "string", + "description": "The bash command to execute." + }, + "description": { + "type": "string", + "description": "Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"." + }, + "timeoutMs": { + "type": "number", + "description": "Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry." + }, + "workdir": { + "type": "string", + "description": "Working directory for this command. Defaults to the session workspace; a relative path is resolved against it." + }, + "run_in_background": { + "type": "boolean", + "description": "Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this command needs. Only valid as a one-shot retry of a command the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact command needs the wider access." + } + }, + "required": [ + "command", + "description" + ] + } + }, + { + "name": "create_goal", + "description": "Create one persisted same-session completion goal when the current direct human request is a long-running objective that should continue across autonomous goal rounds. You may infer that intent without requiring the user to say \"create a goal\". Do not use this for trivial single-turn work. Execution rejects non-human and subagent authority.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The concrete completion objective inferred from the direct human request." + }, + "max_goal_rounds": { + "type": "number", + "description": "Optional positive safe-integer limit on automatic continuation rounds." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "edit", + "description": "Edit an existing UTF-8 text file by replacing literal text.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to edit, resolved by the filesystem backend." + }, + "old_string": { + "type": "string", + "description": "Literal text to replace. Must match exactly." + }, + "new_string": { + "type": "string", + "description": "Literal replacement text. Use an empty string to delete the match." + }, + "replace_all": { + "type": "boolean", + "description": "Replace all matches. Defaults to false; when false, old_string must appear exactly once." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "old_string", + "new_string" + ] + } + }, + { + "name": "get_goal", + "description": "Read the current same-session goal, including its exact id/revision, objective, phase, completed continuation rounds, round limit, blocker reason when present, and whether another continuation is armed. Call this before updating a goal.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "interrupt_agent", + "description": "Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.", + "parameters": { + "type": "object", + "properties": { + "agent_id": { + "type": "string", + "description": "The agent id of the running agent to interrupt." + } + }, + "required": [ + "agent_id" + ] + } + }, + { + "name": "list_agents", + "description": "List your continuable background subagents by durable id and label. Status comes from the live registry: running means the agent is working right now, idle means it is loaded but between turns (it may be waiting on agents it started), and complete means it exists only in storage — a direct child remains a `send_message` candidate in every status. The snapshot is not a delivery promise — `send_message` performs the authoritative check and may still fail. Children that could not be read are reported as diagnostics instead of being silently dropped. Scope `descendants` walks the whole tree below you in stable pre-order, annotating each entry with its durable direct-parent session id and depth. You may use `send_message` only for depth-1 entries; deeper entries are candidates for `interrupt_agent` only.", + "parameters": { + "type": "object", + "properties": { + "scope": { + "type": "string", + "description": "children (default) lists direct children only; descendants walks the complete tree below you.", + "enum": [ + "children", + "descendants" + ] + } + } + } + }, + { + "name": "ralph", + "description": "Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.", + "parameters": { + "type": "object", + "properties": { + "objective": { + "type": "string", + "description": "The immutable completion objective for every fresh Ralph round." + }, + "maxRounds": { + "type": "number", + "description": "Optional positive safe-integer round cap, bounded by the deployment ceiling." + } + }, + "required": [ + "objective" + ] + } + }, + { + "name": "read", + "description": "Read a UTF-8 text file and return line-numbered content.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to read, resolved by the filesystem backend." + }, + "offset": { + "type": "number", + "description": "1-based first line to return. Defaults to 1." + }, + "limit": { + "type": "number", + "description": "Maximum number of lines to return. Defaults to 2000." + } + }, + "required": [ + "file_path" + ] + } + }, + { + "name": "report", + "description": "Report selected content to the agent that started you. Call this zero or more times for progress, findings, or a final answer. Reporting does not end your turn or finish your work, and only your direct parent receives it. A failed call may still have arrived, so do not blindly repeat it.", + "parameters": { + "type": "object", + "properties": { + "output": { + "type": "string", + "description": "Self-contained content for your parent; it does not see your private work." + } + }, + "required": [ + "output" + ] + } + }, + { + "name": "send_message", + "description": "Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.", + "parameters": { + "type": "object", + "properties": { + "subagent_id": { + "type": "string", + "description": "The subagent id returned when the background subagent was started." + }, + "message": { + "type": "string", + "description": "The message to deliver to the subagent." + } + }, + "required": [ + "subagent_id", + "message" + ] + } + }, + { + "name": "skill", + "description": "Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.", + "parameters": { + "type": "object", + "properties": { + "name": { + "type": "string", + "description": "The exact skill name from the available skills list." + } + }, + "required": [ + "name" + ] + } + }, + { + "name": "subagent", + "description": "Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "subagent_fork", + "description": "Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.", + "parameters": { + "type": "object", + "properties": { + "description": { + "type": "string", + "description": "A short (3-5 word) description of the delegated task, for display." + }, + "prompt": { + "type": "string", + "description": "The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new." + }, + "run_in_background": { + "type": "boolean", + "description": "Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message." + } + }, + "required": [ + "description", + "prompt" + ] + } + }, + { + "name": "task_kill", + "description": "Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "reason": { + "type": "string", + "description": "Optional short reason, recorded in the log and forwarded to the task." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "task_list", + "description": "List your background tasks (running and finished) with their ids, kinds, and statuses.", + "parameters": { + "type": "object", + "properties": {} + } + }, + { + "name": "task_output", + "description": "Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.", + "parameters": { + "type": "object", + "properties": { + "task_id": { + "type": "string", + "description": "Task id returned by the tool that started the background work." + }, + "wait": { + "type": "boolean", + "description": "Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive." + }, + "timeout_ms": { + "type": "number", + "description": "Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum." + } + }, + "required": [ + "task_id" + ] + } + }, + { + "name": "todo_write", + "description": "Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).", + "parameters": { + "type": "object", + "properties": { + "todos": { + "type": "array", + "description": "The COMPLETE task list, replacing any previous list.", + "items": { + "type": "object", + "additionalProperties": false, + "properties": { + "content": { + "type": "string", + "description": "What the task is — a short imperative line." + }, + "status": { + "type": "string", + "description": "pending (not started) | in_progress (now) | completed (done).", + "enum": [ + "pending", + "in_progress", + "completed" + ] + } + }, + "required": [ + "content", + "status" + ] + } + } + }, + "required": [ + "todos" + ] + } + }, + { + "name": "update_goal", + "description": "Update the exact current goal revision. edit, pause, and resume require a direct top-level human request. During an automatic continuation of the current goal, complete and blocked are also allowed. blocked is rejected before the configured minimum round count; the model remains responsible for judging that the same condition persisted across those rounds and must explain it in blocked_reason.", + "parameters": { + "type": "object", + "properties": { + "goal_id": { + "type": "string", + "description": "Exact id returned by get_goal." + }, + "revision": { + "type": "number", + "description": "Exact positive revision returned by get_goal." + }, + "action": { + "type": "string", + "description": "edit | pause | resume | complete | blocked", + "enum": [ + "edit", + "pause", + "resume", + "complete", + "blocked" + ] + }, + "objective": { + "type": "string", + "description": "Replacement objective; valid only with action edit." + }, + "max_goal_rounds": { + "type": "number", + "description": "Replacement cap; valid only with action edit." + }, + "blocked_reason": { + "type": "string", + "description": "Concrete blocking condition; required only with action blocked." + } + }, + "required": [ + "goal_id", + "revision", + "action" + ] + } + }, + { + "name": "workflow", + "description": "Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.", + "parameters": { + "type": "object", + "properties": { + "script": { + "type": "string", + "description": "The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)." + }, + "meta": { + "type": "object", + "description": "The workflow identity block (plain JSON — never code).", + "additionalProperties": true, + "properties": { + "name": { + "type": "string", + "description": "Short kebab-case workflow name." + }, + "description": { + "type": "string", + "description": "One-line description of what the workflow does." + }, + "whenToUse": { + "type": "string", + "description": "Optional guidance on when this workflow applies." + }, + "phases": { + "type": "array", + "description": "Optional phase declarations matched by phase() calls.", + "items": { + "type": "object", + "additionalProperties": true, + "properties": { + "title": { + "type": "string", + "description": "The phase title phase() calls match by exact string." + }, + "detail": { + "type": "string", + "description": "Optional one-line description of the phase." + }, + "provider": { + "type": "string", + "description": "Optional provider override this phase is expected to use." + }, + "model": { + "type": "string", + "description": "Optional model override this phase is expected to use." + } + }, + "required": [ + "title" + ] + } + } + }, + "required": [ + "name", + "description" + ] + }, + "args": { + "type": "object", + "description": "Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).", + "additionalProperties": true + } + }, + "required": [ + "script", + "meta" + ] + } + }, + { + "name": "write", + "description": "Create or fully replace a UTF-8 text file.", + "parameters": { + "type": "object", + "properties": { + "file_path": { + "type": "string", + "description": "Path to write, resolved by the filesystem backend." + }, + "content": { + "type": "string", + "description": "Full UTF-8 text content to write." + }, + "sandbox_permissions": { + "type": "string", + "description": "The wider sandbox mode this file operation needs. Only valid as a one-shot retry of an operation the sandbox just denied; requires justification and user approval.", + "enum": [ + "workspace-write", + "danger-full-access" + ] + }, + "justification": { + "type": "string", + "description": "Required with sandbox_permissions: one sentence for the user explaining why this exact file operation needs the wider access." + } + }, + "required": [ + "file_path", + "content" + ] + } + } + ], + "changes": [] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl index 554de6f448..9352ca6fb9 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.1.jsonl @@ -1,37 +1,38 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785544945198,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785544945198,"data":{}} -{"type":"agent/inbox/spliced","seq":2,"time":1785730451347,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"}]}} -{"type":"turn/start","seq":3,"time":1785821409024,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":4,"time":1785730917162,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"agent/inbox/spliced","seq":5,"time":1785730917192,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"}]}} -{"type":"agent/inbox/spliced","seq":6,"time":1785821409076,"data":{"target":"next-turn","start":1,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"755c76db-6ee8-432d-a2d0-f8a3b7914e08"}]}} -{"type":"step/start","seq":7,"time":1785730917198,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":8,"time":1785730917198,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"},"surfaceOp":"append"} -{"type":"user/message","seq":9,"time":1785730917198,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"49acbc16-4d58-460e-8cc0-62838472dce6"},"surfaceOp":"append"} -{"type":"session/title","seq":10,"time":1785730917198,"data":{"title":"Reply with exactly the word","messageSeqs":[8],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":11,"time":1785730917198,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":12,"time":1785730917199,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":13,"time":1785730696668,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":14,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":15,"time":1789000000011,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":16,"time":1789000000012,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":17,"time":1785730451397,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":18,"time":1785730696668,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"178ea526-9e19-49d2-b3b0-57b682320028"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[13,14,15,16,17],"surfaceOp":"append"} -{"type":"step/end","seq":19,"time":1785730696668,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":20,"time":1785730696669,"data":{"turn":1,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":21,"time":1785821409092,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":22,"time":1785821409092,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":23,"time":1785730696682,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":24,"time":1785730696682,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"},"surfaceOp":"append"} -{"type":"assistant/chunk","seq":25,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":26,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} -{"type":"assistant/chunk","seq":27,"time":1789000000023,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} -{"type":"assistant/chunk","seq":28,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":29,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":30,"time":1785730696686,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ced209bf-5d6d-4880-b187-18cb816a150c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[25,26,27,28,29],"surfaceOp":"append"} -{"type":"step/end","seq":31,"time":1785730696686,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":32,"time":1785730696686,"data":{"turn":2,"reason":{"kind":"completed"}}} -{"type":"turn/start","seq":33,"time":1785821409110,"data":{"turn":3}} -{"type":"agent/inbox/spliced","seq":34,"time":1785821409110,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"turn/end","seq":35,"time":1785821409122,"data":{"turn":3,"reason":{"kind":"error","error":{"message":"snapshot disk full","code":"UNKNOWN"}}}} +{"type":"approval/policy","seq":2,"time":1786357526242,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357526243,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"}]}} +{"type":"turn/start","seq":4,"time":1786357526243,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":1785730917192,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"agent/inbox/spliced","seq":6,"time":1785821409076,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"}]}} +{"type":"agent/inbox/spliced","seq":7,"time":1786357526278,"data":{"target":"next-turn","start":1,"inserted":[{"content":[{"type":"text","text":"Now reply with exactly THIRD_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"755c76db-6ee8-432d-a2d0-f8a3b7914e08"}]}} +{"type":"step/start","seq":8,"time":1786357526284,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":9,"time":1785730917198,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"c67a308f-d867-424e-b198-c9f464228703"},"surfaceOp":"append"} +{"type":"user/message","seq":10,"time":1786357526284,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"7f1d7407-d9bc-4ec6-ae42-a8767e0e1153"},"surfaceOp":"append"} +{"type":"session/title","seq":11,"time":1786357526284,"data":{"title":"Reply with exactly the word","messageSeqs":[9],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":12,"time":1785730917198,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":13,"time":1785730917199,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":14,"time":1785730696668,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":15,"time":1789000000010,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":16,"time":1789000000011,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":17,"time":1789000000012,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":18,"time":1785730451397,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":19,"time":1785730696668,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"178ea526-9e19-49d2-b3b0-57b682320028"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[14,15,16,17,18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":1785730696668,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":21,"time":1785730696669,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":22,"time":1785821409092,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":23,"time":1785821409092,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":24,"time":1785730696682,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":25,"time":1785730696682,"data":{"content":[{"type":"text","text":"Now reply with exactly SECOND_OK."}],"source":{"kind":"coordinator","form":"relay","senderSessionId":"11111111-1111-4111-8111-111111111111"},"role":"user","id":"e7d15a94-203d-43ab-8279-4e22d5218feb"},"surfaceOp":"append"} +{"type":"assistant/chunk","seq":26,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":27,"time":1785730696686,"data":{"turn":2,"step":1,"chunk":{"type":"text-delta","index":0,"text":"SECOND_OK"}}} +{"type":"assistant/chunk","seq":28,"time":1789000000023,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"SECOND_OK"}}}} +{"type":"assistant/chunk","seq":29,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":30,"time":1785730451421,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":31,"time":1785730696686,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"SECOND_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ced209bf-5d6d-4880-b187-18cb816a150c"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[26,27,28,29,30],"surfaceOp":"append"} +{"type":"step/end","seq":32,"time":1785730696686,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":33,"time":1785730696686,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"turn/start","seq":34,"time":1785821409110,"data":{"turn":3}} +{"type":"agent/inbox/spliced","seq":35,"time":1785821409110,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"turn/end","seq":36,"time":1785821409122,"data":{"turn":3,"reason":{"kind":"error","error":{"message":"snapshot disk full","code":"UNKNOWN"}}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl index 898d4b250c..a759c35a32 100644 --- a/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-continuable/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821408972,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730451327,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785730451327,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. 2. Call send_message twice in a row, both with the subagent id from step 1: first with message 'Now reply with exactly SECOND_OK.', then with message 'Now reply with exactly THIRD_OK.'. 3. Call send_message with subagent_id exactly '22222222-2222-4222-8222-222222222222' (a subagent that does not exist) and message 'Please continue.', and observe that it fails. 4. Reply with the single word DONE. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"125665d3-8c03-4190-b4f9-c27d61d245f4"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730451328,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e7521889-28d8-4434-84b2-21ff0e044fe7"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730451328,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"533e9513-a329-4a36-9a8d-ddaf544b57c3"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730451328,"data":{"title":"Follow these steps exactly, then","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730451329,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730451329,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl index 8b8b8cc0c2..44d587c851 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.1.jsonl @@ -1,29 +1,30 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1001,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498798860,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"}]}} -{"type":"turn/start","seq":1,"time":1785821414174,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821414174,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821414185,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} -{"type":"step/start","seq":4,"time":1785730456013,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730456014,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730456014,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"3244b13c-f211-445f-acf5-fb8d1534537c"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730456014,"data":{"title":"Call subagent once. Ask that","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730456014,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730456014,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_one_child","name":"subagent","argumentsDelta":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}} -{"type":"assistant/chunk","seq":12,"time":1785498798883,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}}} -{"type":"assistant/chunk","seq":13,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":14,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":15,"time":1785730456018,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"21044d12-2e0e-40e3-b47e-4920e21c3e83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"tool/call","seq":16,"time":1785730456019,"data":{"turn":1,"step":1,"callId":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}} -{"type":"tool/result","seq":17,"time":1785730456072,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_one_child"},"content":[{"type":"tool-result","toolCallId":"call_depth_one_child","content":[{"type":"text","text":"DEPTH_REJECTED"}],"isError":false}],"role":"user","id":"aa5451a8-812b-4a51-a52c-dbc5c84f16d0"}},"sourceEventSeqs":[16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1785730456072,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":19,"time":1785730456082,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":20,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_ONE_DONE"}}} -{"type":"assistant/chunk","seq":22,"time":1785498798949,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_ONE_DONE"}}}} -{"type":"assistant/chunk","seq":23,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":24,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":25,"time":1785730456086,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a1911c6-f487-458c-b802-4a66221ec046"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":1785730456086,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":27,"time":1785730456086,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357533581,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357533582,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"}]}} +{"type":"turn/start","seq":2,"time":1786357533582,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357533582,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357533600,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth one"}} +{"type":"step/start","seq":5,"time":1786357533602,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730456014,"data":{"content":[{"type":"text","text":"Call subagent once. Ask that child to attempt one further subagent call, then report the result."}],"source":{"kind":"user"},"role":"user","id":"a8129357-1bde-4cbd-90b4-6b8ad51d52e1"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357533602,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"82a11d62-da49-4ad3-a243-789ea3cd7c08"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357533602,"data":{"title":"Call subagent once. Ask that","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730456014,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730456014,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_one_child","name":"subagent","argumentsDelta":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}} +{"type":"assistant/chunk","seq":13,"time":1785498798883,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}}}} +{"type":"assistant/chunk","seq":14,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":15,"time":1785730456018,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":16,"time":1785730456018,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"21044d12-2e0e-40e3-b47e-4920e21c3e83"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","seq":17,"time":1785730456019,"data":{"turn":1,"step":1,"callId":"call_depth_one_child","name":"subagent","arguments":"{\"description\":\"Start depth two\",\"prompt\":\"Attempt one subagent call beyond the configured cap, then report the rejection.\"}"}} +{"type":"tool/result","seq":18,"time":1785730456072,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_one_child"},"content":[{"type":"tool-result","toolCallId":"call_depth_one_child","content":[{"type":"text","text":"DEPTH_REJECTED"}],"isError":false}],"role":"user","id":"aa5451a8-812b-4a51-a52c-dbc5c84f16d0"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1785730456072,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1785730456082,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1784540790365,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_ONE_DONE"}}} +{"type":"assistant/chunk","seq":23,"time":1785498798949,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_ONE_DONE"}}}} +{"type":"assistant/chunk","seq":24,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":25,"time":1785730456086,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":26,"time":1785730456086,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_ONE_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5a1911c6-f487-458c-b802-4a66221ec046"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1785730456086,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":28,"time":1785730456086,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl index 7f2ae89966..89de9b1c4f 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.2.jsonl @@ -1,29 +1,30 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1002,"cwd":"{{cwd}}","parentSession":"22222222-2222-4222-8222-222222222222","origin":"subagent","delegationDepth":2} -{"type":"agent/inbox/spliced","seq":0,"time":1785498798891,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"}]}} -{"type":"turn/start","seq":1,"time":1785821414201,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821414201,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821414214,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} -{"type":"step/start","seq":4,"time":1785730456041,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730456041,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730456041,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4a252f7d-8523-433f-a3fc-33812be802ec"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730456041,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730456041,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730456042,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_three_rejected","name":"subagent","argumentsDelta":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}} -{"type":"assistant/chunk","seq":12,"time":1785498798916,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}}} -{"type":"assistant/chunk","seq":13,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":14,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":15,"time":1785730456047,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"467433db-5dbf-42ee-94c0-25c011ce711b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} -{"type":"tool/call","seq":16,"time":1785730456048,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}} -{"type":"tool/result","seq":17,"time":1785730456056,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_three_rejected"},"content":[{"type":"tool-result","toolCallId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true}],"role":"user","id":"9a3d59f3-542a-4400-a62c-be28dcea3bd1"}},"sourceEventSeqs":[16],"surfaceOp":"append"} -{"type":"step/end","seq":18,"time":1785730456056,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":19,"time":1785730456066,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":20,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":21,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_REJECTED"}}} -{"type":"assistant/chunk","seq":22,"time":1785498798937,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_REJECTED"}}}} -{"type":"assistant/chunk","seq":23,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} -{"type":"assistant/chunk","seq":24,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":25,"time":1785730456070,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"57c0ecaf-3f72-4da9-9eb9-a0726e8f097a"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[20,21,22,23,24],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":1785730456071,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":27,"time":1785730456071,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357533611,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357533611,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"}]}} +{"type":"turn/start","seq":2,"time":1786357533611,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357533611,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357533628,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Start depth two"}} +{"type":"step/start","seq":5,"time":1786357533630,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730456041,"data":{"content":[{"type":"text","text":"Attempt one subagent call beyond the configured cap, then report the rejection."}],"source":{"kind":"user"},"role":"user","id":"d4dc5a16-e542-4dd9-8e82-e6b7829cfc4b"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357533630,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"d5488efe-eea2-4019-8fcb-7e6e49077d8a"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357533630,"data":{"title":"Attempt one subagent call beyond","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730456041,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730456042,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_depth_three_rejected","name":"subagent","argumentsDelta":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}} +{"type":"assistant/chunk","seq":13,"time":1785498798916,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}}}} +{"type":"assistant/chunk","seq":14,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":15,"time":1785730456047,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":16,"time":1785730456047,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"467433db-5dbf-42ee-94c0-25c011ce711b"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","seq":17,"time":1785730456048,"data":{"turn":1,"step":1,"callId":"call_depth_three_rejected","name":"subagent","arguments":"{\"description\":\"Exceed depth cap\",\"prompt\":\"This child must never start.\"}"}} +{"type":"tool/result","seq":18,"time":1785730456056,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_depth_three_rejected"},"content":[{"type":"tool-result","toolCallId":"call_depth_three_rejected","content":[{"type":"text","text":"Error: subagent depth 3 exceeds maxDepth 2"}],"isError":true}],"role":"user","id":"9a3d59f3-542a-4400-a62c-be28dcea3bd1"}},"sourceEventSeqs":[17],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1785730456056,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1785730456066,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1784540790339,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"DEPTH_REJECTED"}}} +{"type":"assistant/chunk","seq":23,"time":1785498798937,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DEPTH_REJECTED"}}}} +{"type":"assistant/chunk","seq":24,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":25,"time":1785730456070,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":26,"time":1785730456070,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"DEPTH_REJECTED"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"57c0ecaf-3f72-4da9-9eb9-a0726e8f097a"},"usage":{"inputTokens":10,"outputTokens":2}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1785730456071,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":28,"time":1785730456071,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl index 6288b6d516..ab699d7d18 100644 --- a/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-depth-two-rejection/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821414127,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1784540790308,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498798839,"data":{"content":[{"type":"text","text":"Delegate through two child generations. The depth-two child must attempt one more subagent call and report the rejection."}],"source":{"kind":"user"},"role":"user","id":"b2260a25-4667-49ed-9297-16b233f22332"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730455980,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"d3ba1b18-4d27-4c90-a95d-125e9ffc9f29"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730455980,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"55365caf-6fcc-484b-a4b7-646914654bbb"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730455980,"data":{"title":"Delegate through two child generations.","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498798841,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730455981,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl index a56f7ccf60..c663606bbd 100644 --- a/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-fork/session.1.jsonl @@ -20,21 +20,23 @@ {"type":"step/end","seq":40,"time":1785730448979,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":41,"time":1785730448979,"data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","seq":42,"time":1785730449008,"data":{}} -{"type":"agent/inbox/spliced","seq":43,"time":1785498796160,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"}]}} -{"type":"turn/start","seq":44,"time":1785821406523,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":45,"time":1785821406523,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":46,"time":1785821406543,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} -{"type":"step/start","seq":47,"time":1785730449027,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":48,"time":1785730449027,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"},"surfaceOp":"append"} -{"type":"request/header","seq":49,"time":1785730449027,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":50,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":51,"time0":1783352138046,"data":{"turn":2,"step":1,"index":0,"dt":[0,28,1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1,0,0,0,0,30,2],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}} -{"type":"assistant/chunk","seq":85,"time":1783352138307,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":86,"time0":1783352138307,"data":{"turn":2,"step":1,"index":1,"dt":[1790166963,239266980,117223942],"texts":["M","ARM","AL","ADE"]}} -{"type":"assistant/chunk","seq":90,"time":1785498796192,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} -{"type":"assistant/chunk","seq":91,"time":1785498796192,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} -{"type":"assistant/chunk","seq":92,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} -{"type":"assistant/chunk","seq":93,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":94,"time":1785730449034,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc56e00-c648-4669-92b2-7299e41cb743"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93],"surfaceOp":"append"} -{"type":"step/end","seq":95,"time":1785730449035,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":96,"time":1785730449035,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":43,"time":1786357523264,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":44,"time":1786357523265,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"}]}} +{"type":"turn/start","seq":45,"time":1786357523265,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":46,"time":1786357523265,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":47,"time":1786357523283,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} +{"type":"step/start","seq":48,"time":1786357523286,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":49,"time":1786357523286,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"d037163e-ed56-4c9c-b5d1-57df017d618c"},"surfaceOp":"append"} +{"type":"user/message","seq":50,"time":1786358035356,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"257e572f-6f95-48f9-b3d7-4ea8b162f374"},"surfaceOp":"append"} +{"type":"request/header","seq":51,"time":1786358035356,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":52,"time":1783352138046,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":53,"time0":1783352138074,"data":{"turn":2,"step":1,"index":0,"dt":[1,0,0,0,0,28,0,0,0,0,28,28,1,0,0,28,0,0,29,0,0,28,1,28,1,0,0,0,0,30,2,0,0],"texts":["The"," user"," asked"," me"," to"," remember"," the"," project"," cod","ew","ord"," \"","M","ARM","AL","ADE","\""," and"," now"," they","'re"," asking"," what"," it"," is","."," I"," should"," just"," reply"," with"," that"," word","."]}} +{"type":"assistant/chunk","seq":87,"time":1785142305270,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":88,"time0":1785381572250,"data":{"turn":2,"step":1,"index":1,"dt":[117223942,0,0],"texts":["M","ARM","AL","ADE"]}} +{"type":"assistant/chunk","seq":92,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."}}}} +{"type":"assistant/chunk","seq":93,"time":1785730449034,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"MARMALADE"}}}} +{"type":"assistant/chunk","seq":94,"time":1786357523292,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}}}} +{"type":"assistant/chunk","seq":95,"time":1786358035361,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":96,"time":1786358035361,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to remember the project codeword \"MARMALADE\" and now they're asking what it is. I should just reply with that word."},{"type":"text","text":"MARMALADE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc56e00-c648-4669-92b2-7299e41cb743"},"usage":{"inputTokens":97,"outputTokens":39,"cacheReadTokens":2816,"reasoningTokens":34}},"sourceEventSeqs":[52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85,86,87,88,89,90,91,92,93,94,95],"surfaceOp":"append"} +{"type":"step/end","seq":97,"time":1786358035361,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":98,"time":1786358035361,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl index d6e54e2096..d81f1964b2 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.1.jsonl @@ -1,20 +1,21 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785531795641,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Reply with CHILD_OK","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785531795641,"data":{}} -{"type":"agent/inbox/spliced","seq":2,"time":1785730454803,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"}]}} -{"type":"turn/start","seq":3,"time":1785821412774,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":4,"time":1785821412774,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":5,"time":1785730454835,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":6,"time":1785730454835,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1785730454835,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"524be394-8639-4c12-a41d-799b9e0120a1"},"surfaceOp":"append"} -{"type":"session/title","seq":8,"time":1785730454835,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":9,"time":1785730454835,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":10,"time":1785730454835,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":11,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":12,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} -{"type":"assistant/chunk","seq":13,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":14,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":15,"time":1785730454843,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":16,"time":1785730454843,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f6a952dd-2d09-4b5c-b8ae-5456cfdfeab0"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} -{"type":"step/end","seq":17,"time":1785730454843,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":18,"time":1785730454844,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":2,"time":1786357532080,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357532080,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"}]}} +{"type":"turn/start","seq":4,"time":1786357532081,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":1786357532081,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":1786357532106,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":7,"time":1785730454835,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"2a46160e-89d3-433b-bf04-66fb0313abfa"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":1786357532106,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"5fda3f8d-fbac-4878-a9e3-9953a4e1da09"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":1786357532106,"data":{"title":"Reply with exactly the word","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":1785730454835,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":1785730454835,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":1789000000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":13,"time":1789000000008,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"CHILD_OK"}}} +{"type":"assistant/chunk","seq":14,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":15,"time":1785531795683,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":16,"time":1785730454843,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":17,"time":1785730454843,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f6a952dd-2d09-4b5c-b8ae-5456cfdfeab0"},"usage":{"inputTokens":10,"outputTokens":3}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"step/end","seq":18,"time":1785730454843,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":19,"time":1785730454844,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl index f1deec5af9..06bd463ba8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-list-agents/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821412725,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730454783,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785730454783,"data":{"content":[{"type":"text","text":"Call the subagent tool once with run_in_background set to true, description 'Reply with CHILD_OK', and prompt 'Reply with exactly the word CHILD_OK and nothing else.'. Then reply with the single word STARTED. Do not call any other tool."}],"source":{"kind":"user"},"role":"user","id":"c2febfff-792d-4457-a944-933ff0de0570"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730454783,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"cc8cb20d-5802-46a9-87b8-d3ee784f8e52"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730454783,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"c9d1f853-56bc-4082-ae08-00d4bcbb04a6"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730454783,"data":{"title":"Call the subagent tool once","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730454784,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730454784,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/input.json b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/input.json new file mode 100644 index 0000000000..640bf92f7f --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/input.json @@ -0,0 +1,14 @@ +{ + "steps": [ + { + "op": "initialize" + }, + { + "op": "newSession" + }, + { + "op": "prompt", + "text": "Use the subagent tool exactly once to delegate this subtask: \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\" After the subagent returns, reply with the single word PARENT_DONE and stop." + } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl new file mode 100644 index 0000000000..d1f5447275 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl @@ -0,0 +1,31 @@ +{"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":2,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} +{"type":"approval/policy","seq":0,"time":1786373921809,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786373921809,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Write the words 'partial one', call todo_write once, then keep going until you are cut off."}],"source":{"kind":"user"},"role":"user","id":"dbf0670a-79cc-4e2c-a298-c4d804e6fe61"}]}} +{"type":"turn/start","seq":2,"time":1786373921810,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786373921810,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786373921831,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Truncated child"}} +{"type":"step/start","seq":5,"time":1786373921836,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1786348800142,"data":{"content":[{"type":"text","text":"Write the words 'partial one', call todo_write once, then keep going until you are cut off."}],"source":{"kind":"user"},"role":"user","id":"dbf0670a-79cc-4e2c-a298-c4d804e6fe61"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786373921837,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"885ea744-63dd-4198-95be-267b9db94a57"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786373921837,"data":{"title":"Write the words 'partial one',","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1786348800142,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1786348800142,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"partial one"}}}} +{"type":"assistant/chunk","seq":13,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":14,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}}}} +{"type":"assistant/chunk","seq":15,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":9}}}} +{"type":"assistant/chunk","seq":16,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":17,"time":1786348800146,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"partial one"},{"type":"tool-call","id":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5e4d07b2-6ce2-4ab6-8be0-fbdf2d3af138"},"usage":{"inputTokens":20,"outputTokens":9}},"sourceEventSeqs":[11,12,13,14,15,16],"surfaceOp":"append"} +{"type":"tool/call","seq":18,"time":1786348800146,"data":{"turn":1,"step":1,"callId":"call_child_1","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"in_progress\"}]}"}} +{"type":"todo/write","seq":19,"time":1786348800150,"data":{"todos":[{"content":"keep going","status":"in_progress"}]}} +{"type":"tool/result","seq":20,"time":1786348800151,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_child_1"},"content":[{"type":"tool-result","toolCallId":"call_child_1","content":[{"type":"text","text":"Updated todo list: 0 pending, 1 in progress, 0 completed."}],"isError":false}],"role":"user","id":"67efbbf3-ca1e-4d23-8f19-940cb391ff1e"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"step/end","seq":21,"time":1786348800151,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":22,"time":1786348800156,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":23,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":24,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_child_2","name":"todo_write","arguments":"{\"todos\": [{\"content\": \"keep going\", \"status\": \"completed\"}]}"}}}} +{"type":"assistant/chunk","seq":25,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":26,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"max-tokens"}}}} +{"type":"assistant/message","seq":27,"time":1786348800160,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bb92e4ec-f260-4415-9782-b71147ea378d"},"usage":{"inputTokens":30,"outputTokens":4}},"sourceEventSeqs":[23,24,25,26],"surfaceOp":"append"} +{"type":"step/end","seq":28,"time":1786348800160,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":29,"time":1786348800160,"data":{"turn":1,"reason":{"kind":"max-tokens"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.jsonl new file mode 100644 index 0000000000..f07b01589d --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.jsonl @@ -0,0 +1,26 @@ +{"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786348800078,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask: \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\" After the subagent returns, reply with the single word PARENT_DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"8787ce07-4f1f-4368-bf58-18e30484ed44"}]}} +{"type":"turn/start","seq":1,"time":1786348800079,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786348800079,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786348800114,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786348800114,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask: \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\" After the subagent returns, reply with the single word PARENT_DONE and stop."}],"source":{"kind":"user"},"role":"user","id":"8787ce07-4f1f-4368-bf58-18e30484ed44"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786348800114,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"32a8b2ce-f1f9-411b-940d-c80f772561ac"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786348800114,"data":{"title":"Use the subagent tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786348800115,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786348800115,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_parent_1","name":"subagent","arguments":"{\"description\": \"Truncated child\", \"prompt\": \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":12,"time":1786348800120,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":1786348800120,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_parent_1","name":"subagent","arguments":"{\"description\": \"Truncated child\", \"prompt\": \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"f4269cd2-9132-4b68-8f9b-ff3a40321bc9"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":1786348800121,"data":{"turn":1,"step":1,"callId":"call_parent_1","name":"subagent","arguments":"{\"description\": \"Truncated child\", \"prompt\": \"Write the words 'partial one', call todo_write once, then keep going until you are cut off.\"}"}} +{"type":"tool/result","seq":15,"time":1786348800163,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parent_1"},"content":[{"type":"tool-result","toolCallId":"call_parent_1","content":[{"type":"text","text":"Error: subagent run hit its token limit before finishing\nPartial output before the run ended:\npartial one"}],"isError":true}],"role":"user","id":"5dd34050-a533-4f1b-99ee-5fc62c6a4502"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1786348800163,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":1786348800169,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":18,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":19,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":20,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":12,"outputTokens":2}}}} +{"type":"assistant/chunk","seq":21,"time":1786348800173,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":22,"time":1786348800173,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"fb14560d-1d98-4b18-8736-b079de400315"},"usage":{"inputTokens":12,"outputTokens":2}},"sourceEventSeqs":[18,19,20,21],"surfaceOp":"append"} +{"type":"step/end","seq":23,"time":1786348800173,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":24,"time":1786348800173,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl new file mode 100644 index 0000000000..a460e019d4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl index e275607cbf..e510929344 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.1.jsonl @@ -1,22 +1,23 @@ {"type":"session","version":0,"id":"e4aafa18-b9e3-48d0-8aae-6c9b25dcae80","createdAt":1783352145223,"cwd":"{{cwd}}","parentSession":"959ffdf5-03e2-465e-9482-009b704632dc","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498797416,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"}]}} -{"type":"turn/start","seq":1,"time":1785821407754,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821407754,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821407767,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} -{"type":"step/start","seq":4,"time":1785730450187,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730450187,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730450187,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"4a5a7c59-b6f8-47b0-8c09-d9a05607deac"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730450187,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730450187,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730450188,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783352146014,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783352146042,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,28,0,0,0,0,0,29,0,0,0,0,29,0,0],"texts":["The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":30,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":31,"time0":1783352146129,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} -{"type":"assistant/chunk","seq":34,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":35,"time":1785498797444,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} -{"type":"assistant/chunk","seq":36,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":37,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":38,"time":1785730450194,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cfff210d-8dd3-4acc-bbc3-fa860baf88cf"},"usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37],"surfaceOp":"append"} -{"type":"step/end","seq":39,"time":1785730450194,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":40,"time":1785730450195,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357524735,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357524735,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"}]}} +{"type":"turn/start","seq":2,"time":1786357524735,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357524735,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357524752,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply ALPHA only"}} +{"type":"step/start","seq":5,"time":1786357524755,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730450187,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"73ce401a-faaf-408a-879e-7485380d537d"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357524755,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"56ad93fa-0cc0-4ccb-a1b4-258f4801c681"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357524755,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730450187,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730450188,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783352146014,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783352146042,"data":{"turn":1,"step":1,"index":0,"dt":[1,0,0,0,28,0,0,0,0,0,29,0,0,0,0,29,0,0],"texts":["The"," user"," asked"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":31,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":32,"time0":1783352146129,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} +{"type":"assistant/chunk","seq":35,"time":1783352146129,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":36,"time":1785498797444,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":37,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":38,"time":1785730450194,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":39,"time":1785730450194,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user asked me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cfff210d-8dd3-4acc-bbc3-fa860baf88cf"},"usage":{"inputTokens":48,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785730450194,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":41,"time":1785730450195,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl index fb6e5e0971..a5f4ab88a4 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.2.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821407687,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498797379,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3d1ea7cb-c273-4c38-a765-5ff256eaaf51"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730450135,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"473ecf9e-52c4-4db2-be56-1c8f7fa7d932"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730450135,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e0a9678e-ff95-49f4-b4f7-4ace69a670a3"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730450135,"data":{"title":"Remember this fact for later:","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498797380,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730450136,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} @@ -20,21 +20,23 @@ {"type":"step/end","seq":34,"time":1785730450146,"data":{"turn":1,"step":1}} {"type":"turn/end","seq":35,"time":1785730450146,"data":{"turn":1,"reason":{"kind":"completed"}}} {"type":"session/end-seed","seq":36,"time":1785730450227,"data":{}} -{"type":"agent/inbox/spliced","seq":37,"time":1785498797482,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"}]}} -{"type":"turn/start","seq":38,"time":1785821407808,"data":{"turn":2}} -{"type":"agent/inbox/spliced","seq":39,"time":1785821407808,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":40,"time":1785821407826,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} -{"type":"step/start","seq":41,"time":1785730450246,"data":{"turn":2,"step":1}} -{"type":"user/message","seq":42,"time":1785730450246,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"},"surfaceOp":"append"} -{"type":"request/header","seq":43,"time":1785730450247,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} -{"type":"assistant/chunk","seq":44,"time":1783352148076,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":45,"time0":1783352148076,"data":{"turn":2,"step":1,"index":0,"dt":[1,0,0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0,1,0,0,31,1,0],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} -{"type":"assistant/chunk","seq":76,"time":1783352148345,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":77,"time0":1785142306309,"data":{"turn":2,"step":1,"index":1,"dt":[239267243,117223959],"texts":["SA","FF","RON"]}} -{"type":"assistant/chunk","seq":80,"time":1785498797511,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} -{"type":"assistant/chunk","seq":81,"time":1785498797511,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} -{"type":"assistant/chunk","seq":82,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} -{"type":"assistant/chunk","seq":83,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":84,"time":1785730450254,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e1f347c1-ce65-4ca9-8a9e-05e4366ef365"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[44,45,46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83],"surfaceOp":"append"} -{"type":"step/end","seq":85,"time":1785730450254,"data":{"turn":2,"step":1}} -{"type":"turn/end","seq":86,"time":1785730450254,"data":{"turn":2,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":37,"time":1786357524782,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":38,"time":1786357524783,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"}]}} +{"type":"turn/start","seq":39,"time":1786357524783,"data":{"turn":2}} +{"type":"agent/inbox/spliced","seq":40,"time":1786357524783,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":41,"time":1786357524800,"data":{"version":2,"mode":"one-shot","provider":"fork","label":"Recall project codeword"}} +{"type":"step/start","seq":42,"time":1786357524803,"data":{"turn":2,"step":1}} +{"type":"user/message","seq":43,"time":1786357524803,"data":{"content":[{"type":"text","text":"What is the project codeword mentioned earlier in this conversation? Reply with exactly that one word and nothing else."}],"source":{"kind":"user"},"role":"user","id":"86e9f144-764f-460d-b72b-262cffe43d77"},"surfaceOp":"append"} +{"type":"user/message","seq":44,"time":1786358036899,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"6ea5a774-b0da-47ff-84b7-226a4a207bbf"},"surfaceOp":"append"} +{"type":"request/header","seq":45,"time":1786358036900,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"resume"}} +{"type":"assistant/chunk","seq":46,"time":1783352148077,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":47,"time0":1783352148077,"data":{"turn":2,"step":1,"index":0,"dt":[0,0,29,0,0,0,35,0,0,0,0,26,29,31,0,30,0,0,27,1,27,0,1,0,0,31,1,0,0,1790157964],"texts":["The"," user"," is"," asking"," me"," to"," recall"," the"," project"," cod","ew","ord"," that"," was"," mentioned"," earlier"," in"," the"," conversation","."," I"," was"," told"," to"," remember"," it",":"," SA","FF","RON","."]}} +{"type":"assistant/chunk","seq":78,"time":1785381573552,"data":{"turn":2,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":79,"time0":1785498797511,"data":{"turn":2,"step":1,"index":1,"dt":[0,0],"texts":["SA","FF","RON"]}} +{"type":"assistant/chunk","seq":82,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."}}}} +{"type":"assistant/chunk","seq":83,"time":1785730450254,"data":{"turn":2,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"SAFFRON"}}}} +{"type":"assistant/chunk","seq":84,"time":1786357524808,"data":{"turn":2,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}}}} +{"type":"assistant/chunk","seq":85,"time":1786358036906,"data":{"turn":2,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":86,"time":1786358036906,"data":{"turn":2,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user is asking me to recall the project codeword that was mentioned earlier in the conversation. I was told to remember it: SAFFRON."},{"type":"text","text":"SAFFRON"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e1f347c1-ce65-4ca9-8a9e-05e4366ef365"},"usage":{"inputTokens":95,"outputTokens":35,"cacheReadTokens":2816,"reasoningTokens":31}},"sourceEventSeqs":[46,47,48,49,50,51,52,53,54,55,56,57,58,59,60,61,62,63,64,65,66,67,68,69,70,71,72,73,74,75,76,77,78,79,80,81,82,83,84,85],"surfaceOp":"append"} +{"type":"step/end","seq":87,"time":1786358036906,"data":{"turn":2,"step":1}} +{"type":"turn/end","seq":88,"time":1786358036906,"data":{"turn":2,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl index fa390cf176..4963b5275b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-mixed/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821407687,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783352142835,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498797379,"data":{"content":[{"type":"text","text":"Remember this fact for later: the project codeword is SAFFRON. Reply with the single word OK and stop. Do not use any tools."}],"source":{"kind":"user"},"role":"user","id":"3d1ea7cb-c273-4c38-a765-5ff256eaaf51"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730450135,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"473ecf9e-52c4-4db2-be56-1c8f7fa7d932"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730450135,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e0a9678e-ff95-49f4-b4f7-4ace69a670a3"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730450135,"data":{"title":"Remember this fact for later:","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498797380,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730450136,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl index 7948013736..da79d6b23c 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.1.jsonl @@ -1,22 +1,23 @@ {"type":"session","version":0,"id":"553f8e92-aac1-4df3-8657-eacbb58f9581","createdAt":1783352127669,"cwd":"{{cwd}}","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498794788,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"}]}} -{"type":"turn/start","seq":1,"time":1785821405232,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821405232,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821405245,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} -{"type":"step/start","seq":4,"time":1785730447828,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730447828,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730447828,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"bab5cdff-7925-478d-b55a-daa2ef524d7c"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730447828,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730447828,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730447828,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783352128280,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,19,0,0,0,0,1,31,0,0,0,0,32,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":30,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":31,"time0":1783352128365,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} -{"type":"assistant/chunk","seq":34,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":35,"time":1785498794825,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} -{"type":"assistant/chunk","seq":36,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} -{"type":"assistant/chunk","seq":37,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":38,"time":1785730447834,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f1e6087-da72-4a56-9bc0-ae1ac6618a8a"},"usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37],"surfaceOp":"append"} -{"type":"step/end","seq":39,"time":1785730447834,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":40,"time":1785730447834,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357521737,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357521737,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"}]}} +{"type":"turn/start","seq":2,"time":1786357521737,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357521737,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357521754,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return ALPHA only"}} +{"type":"step/start","seq":5,"time":1786357521756,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730447828,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"a287f842-f6f2-4a17-ab4c-820e41f498d5"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357521756,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"f654b5a4-b4c0-4443-8eab-d84624d804f1"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357521756,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730447828,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730447828,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783352128280,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783352128280,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,1,19,0,0,0,0,1,31,0,0,0,0,32,1,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","AL","P","HA","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":31,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":32,"time0":1783352128365,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["AL","P","HA"]}} +{"type":"assistant/chunk","seq":35,"time":1783352128365,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":36,"time":1785498794825,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":37,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}}}} +{"type":"assistant/chunk","seq":38,"time":1785730447834,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":39,"time":1785730447834,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"ALPHA\" and nothing else."},{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"5f1e6087-da72-4a56-9bc0-ae1ac6618a8a"},"usage":{"inputTokens":49,"outputTokens":23,"cacheReadTokens":2816,"reasoningTokens":19}},"sourceEventSeqs":[11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785730447834,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":41,"time":1785730447834,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl index 64e58b3741..f7326e37c8 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.2.jsonl @@ -1,23 +1,24 @@ {"type":"session","version":0,"id":"5f49e80c-16fc-42c7-a617-0b6bd0680aa3","createdAt":1783352129662,"cwd":"{{cwd}}","parentSession":"14dda109-5728-45ba-a002-7db9543fe50e","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498794853,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"}]}} -{"type":"turn/start","seq":1,"time":1785821405286,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821405286,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821405299,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} -{"type":"step/start","seq":4,"time":1785730447881,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730447881,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730447881,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"036067ef-a106-4955-841c-a0d2effe51ef"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730447881,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730447881,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730447881,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783352130413,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,35,0,0,0,0,0,36,0,0,0,0,0,43],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","B","ETA","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":29,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"assistant/chunk","seq":30,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} -{"type":"assistant/chunk","seq":31,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} -{"type":"assistant/chunk","seq":32,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}}} -{"type":"assistant/chunk","seq":33,"time":1785498794882,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} -{"type":"assistant/chunk","seq":34,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":35,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":36,"time":1785730447887,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"adc4527d-efd1-4c89-b42b-826c33f2bb12"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35],"surfaceOp":"append"} -{"type":"step/end","seq":37,"time":1785730447887,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":38,"time":1785730447887,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357521782,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357521783,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"}]}} +{"type":"turn/start","seq":2,"time":1786357521783,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357521783,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357521799,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Return BETA only"}} +{"type":"step/start","seq":5,"time":1786357521801,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730447881,"data":{"content":[{"type":"text","text":"Reply with exactly the word BETA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"53f6419d-8ddc-4eee-8803-5b68411336f9"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357521802,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"05843da4-4a5f-46fc-a00f-5257b2bd271d"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357521802,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730447881,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730447881,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783352130413,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783352130413,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,35,0,0,0,0,0,36,0,0,0,0,0,43],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," \"","B","ETA","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":30,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"assistant/chunk","seq":31,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"B"}}} +{"type":"assistant/chunk","seq":32,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"ETA"}}} +{"type":"assistant/chunk","seq":33,"time":1783352130527,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."}}}} +{"type":"assistant/chunk","seq":34,"time":1785498794882,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"BETA"}}}} +{"type":"assistant/chunk","seq":35,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":36,"time":1785730447887,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730447887,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word \"BETA\" and nothing else."},{"type":"text","text":"BETA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"adc4527d-efd1-4c89-b42b-826c33f2bb12"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":18}},"sourceEventSeqs":[11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730447887,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730447887,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl index 3a48c2d760..a1337e166b 100644 --- a/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-multi/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821405184,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783352126252,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498794765,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE, once at a time, to delegate two subtasks to child agents. First subtask: 'Reply with exactly the word ALPHA and nothing else.' Second subtask (after the first returns): 'Reply with exactly the word BETA and nothing else.' After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"07bf16df-0499-420d-9510-3204061f0122"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730447790,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"9b4b262d-cbd7-4cd8-b24b-70b2b401b0fe"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730447790,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"50a1100d-448e-41f2-8f99-39be199db492"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730447790,"data":{"title":"Use the subagent tool TWICE,","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498794766,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730447791,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/input.json b/examples/acp-agent/tests/snapshots/subagent-parallel/input.json new file mode 100644 index 0000000000..86e72676ee --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/input.json @@ -0,0 +1,7 @@ +{ + "steps": [ + { "op": "initialize" }, + { "op": "newSession" }, + { "op": "prompt", "text": "Use the subagent tool TWICE in the SAME assistant message (two parallel tool calls in one response), each delegating the identical subtask: 'Reply with exactly the word ALPHA and nothing else.' Give both calls the description 'Say the word ALPHA'. After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool." } + ] +} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl new file mode 100644 index 0000000000..3555ca83de --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.1.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"bbbbbbbb-0000-4000-8000-000000000002","createdAt":1783352127000,"cwd":"{{cwd}}","parentSession":"aaaaaaaa-0000-4000-8000-000000000001","origin":"subagent","delegationDepth":1} +{"type":"approval/policy","seq":0,"time":1786373947132,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786373947134,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f5d60a48-78a3-4d75-91d7-478017516c5c"}]}} +{"type":"turn/start","seq":2,"time":1786373947134,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786373947134,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786373947165,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} +{"type":"step/start","seq":5,"time":1786373947168,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1786338530759,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f5d60a48-78a3-4d75-91d7-478017516c5c"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786373947168,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"635fd4be-77c6-4891-90ba-2cafa163e166"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786373947168,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1786338530759,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1786338530759,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1786338530769,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1786338530769,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":13,"time":1786338530769,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":1786338530769,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4d13c6bf-dc50-4e57-91b0-59561b58986e"}},"sourceEventSeqs":[11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1786338530769,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":1786338530769,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl new file mode 100644 index 0000000000..6bb570bdaf --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.2.jsonl @@ -0,0 +1,18 @@ +{"type":"session","version":0,"id":"cccccccc-0000-4000-8000-000000000003","createdAt":1783352127001,"cwd":"{{cwd}}","parentSession":"aaaaaaaa-0000-4000-8000-000000000001","origin":"subagent","delegationDepth":1} +{"type":"approval/policy","seq":0,"time":1786373947132,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786373947133,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"afacd216-5e1b-46e7-afc4-f0eba9da1814"}]}} +{"type":"turn/start","seq":2,"time":1786373947133,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786373947134,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786373947170,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Say the word ALPHA"}} +{"type":"step/start","seq":5,"time":1786373947173,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1786338530749,"data":{"content":[{"type":"text","text":"Reply with exactly the word ALPHA and nothing else."}],"source":{"kind":"user"},"role":"user","id":"afacd216-5e1b-46e7-afc4-f0eba9da1814"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786373947174,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"dde02975-239c-422e-9b63-27d98f73346a"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786373947174,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1786338530749,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1786338530749,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1786338530759,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":12,"time":1786338530759,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ALPHA"}}}} +{"type":"assistant/chunk","seq":13,"time":1786338530759,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":1786338530759,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"ALPHA"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"11e93a53-9112-4e80-9447-de974373ca94"}},"sourceEventSeqs":[11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":1786338530760,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":1786338530760,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/session.jsonl new file mode 100644 index 0000000000..7e2d0e05bb --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/session.jsonl @@ -0,0 +1,28 @@ +{"type":"session","version":0,"id":"aaaaaaaa-0000-4000-8000-000000000001","createdAt":1783352126000,"cwd":"{{cwd}}","delegationDepth":0} +{"type":"agent/inbox/spliced","seq":0,"time":1786338530687,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Use the subagent tool TWICE in the SAME assistant message (two parallel tool calls in one response), each delegating the identical subtask: 'Reply with exactly the word ALPHA and nothing else.' Give both calls the description 'Say the word ALPHA'. After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"02062dd0-83d4-4b40-ab23-2fbcb0a8be96"}]}} +{"type":"turn/start","seq":1,"time":1786338530688,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786338530688,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":3,"time":1786338530716,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":4,"time":1786338530716,"data":{"content":[{"type":"text","text":"Use the subagent tool TWICE in the SAME assistant message (two parallel tool calls in one response), each delegating the identical subtask: 'Reply with exactly the word ALPHA and nothing else.' Give both calls the description 'Say the word ALPHA'. After both subagents return, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"02062dd0-83d4-4b40-ab23-2fbcb0a8be96"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1786338530716,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"2dd192ab-72ed-4c20-a487-40aa14bd5c07"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":1786338530716,"data":{"title":"Use the subagent tool TWICE","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":1786338530717,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":8,"time":1786338530717,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":9,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":10,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_parallel_alpha_1","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":12,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"tool-call","id":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":13,"time":1786338530722,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":14,"time":1786338530723,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_parallel_alpha_1","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"},{"type":"tool-call","id":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6ff33634-55af-4c37-a491-dd5b8673923f"}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"tool/call","seq":15,"time":1786338530723,"data":{"turn":1,"step":1,"callId":"call_parallel_alpha_1","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/call","seq":16,"time":1786338530723,"data":{"turn":1,"step":1,"callId":"call_parallel_alpha_2","name":"subagent","arguments":"{\"description\": \"Say the word ALPHA\", \"prompt\": \"Reply with exactly the word ALPHA and nothing else.\"}"}} +{"type":"tool/result","seq":17,"time":1786338530770,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parallel_alpha_1"},"content":[{"type":"tool-result","toolCallId":"call_parallel_alpha_1","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"9fa4fa59-7326-4bc8-8ea2-120b37d91ea3"}},"sourceEventSeqs":[15],"surfaceOp":"append"} +{"type":"tool/result","seq":18,"time":1786338530770,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_parallel_alpha_2"},"content":[{"type":"tool-result","toolCallId":"call_parallel_alpha_2","content":[{"type":"text","text":"ALPHA"}],"isError":false}],"role":"user","id":"895bb620-534e-4857-9743-68273126eccf"}},"sourceEventSeqs":[16],"surfaceOp":"append"} +{"type":"step/end","seq":19,"time":1786338530770,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":20,"time":1786338530778,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":21,"time":1786338530782,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":22,"time":1786338530782,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"PARENT_DONE"}}}} +{"type":"assistant/chunk","seq":23,"time":1786338530782,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":24,"time":1786338530782,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"PARENT_DONE"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"84586dd8-4985-4286-ab4b-fa9965803fb8"}},"sourceEventSeqs":[21,22,23],"surfaceOp":"append"} +{"type":"step/end","seq":25,"time":1786338530782,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":26,"time":1786338530782,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl b/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl new file mode 100644 index 0000000000..a460e019d4 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-parallel/stdout.expected.jsonl @@ -0,0 +1,4 @@ +{"jsonrpc":"2.0","id":1,"result":{"protocolVersion":1,"agentInfo":{"name":"deepseek-harness-acp","version":"0.0.1"},"agentCapabilities":{"promptCapabilities":{"image":false,"audio":false,"embeddedContext":false}},"authMethods":[]}} +{"jsonrpc":"2.0","id":2,"result":{"sessionId":"{{sessionId}}"}} +{"jsonrpc":"2.0","method":"session/update","params":{"sessionId":"{{sessionId}}","update":{"sessionUpdate":"agent_message_chunk","content":{"type":"text","text":"PARENT_DONE"}}}} +{"jsonrpc":"2.0","id":3,"result":{"stopReason":"end_turn"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.1.jsonl new file mode 100644 index 0000000000..a0433f1290 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-published-run-failure/session.1.jsonl @@ -0,0 +1,2 @@ +{"type":"session","version":0,"id":"eb69342c-62b6-4320-a78b-961745f89333","createdAt":1786358409171,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} +{"type":"approval/policy","seq":0,"time":1786358409171,"data":{"policy":"never","source":"delegation"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl index b9992f1519..e32d1bdee0 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.1.jsonl @@ -1,30 +1,31 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1789000001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} {"type":"subagent/descriptor","seq":0,"time":1785594881508,"data":{"version":2,"mode":"continuable","provider":"spawn","label":"Report a finding","agentProvider":"deepseek-official","agentModel":"deepseek-v4-flash"}} {"type":"session/end-seed","seq":1,"time":1785594881508,"data":{}} -{"type":"agent/inbox/spliced","seq":2,"time":1785730453612,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"}]}} -{"type":"turn/start","seq":3,"time":1785821411475,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":4,"time":1785821411475,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"step/start","seq":5,"time":1785730453639,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":6,"time":1785730453639,"data":{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":1785730453639,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"67c76a21-6142-45a6-9a49-0485f51edc8d"},"surfaceOp":"append"} -{"type":"session/title","seq":8,"time":1785730453639,"data":{"title":"Call the report tool once","messageSeqs":[6],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":9,"time":1785730453639,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":10,"time":1785730453639,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":11,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":12,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_report_1","name":"report","argumentsDelta":"{\"output\": \"CHILD_REPORT_OK\"}"}}} -{"type":"assistant/chunk","seq":13,"time":1789000001010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}}}} -{"type":"assistant/chunk","seq":14,"time":1789000001011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":15,"time":1785730453647,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":16,"time":1785730453647,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c9e50afb-b732-41ab-b0fc-8e98948ad9ec"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[11,12,13,14,15],"surfaceOp":"append"} -{"type":"tool/call","seq":17,"time":1785730453647,"data":{"turn":1,"step":1,"callId":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}} -{"type":"tool/result","seq":18,"time":1785730453654,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_report_1"},"content":[{"type":"tool-result","toolCallId":"call_report_1","content":[{"type":"text","text":"report accepted by the agent that started you as message 824dc60a-f9d7-48ea-a0d4-6d56df83bd4f"}],"isError":false}],"role":"user","id":"e6764773-c667-40b5-a13f-8bdc5a9c7762"}},"sourceEventSeqs":[17],"surfaceOp":"append"} -{"type":"step/end","seq":19,"time":1785730453654,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":20,"time":1785730453664,"data":{"turn":1,"step":2}} -{"type":"assistant/chunk","seq":21,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":22,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Reported."}}} -{"type":"assistant/chunk","seq":23,"time":1789000001020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Reported."}}}} -{"type":"assistant/chunk","seq":24,"time":1789000001021,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} -{"type":"assistant/chunk","seq":25,"time":1785730453668,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":26,"time":1785730453668,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Reported."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"96784835-2d0f-4d00-aef5-ee3a14820dd1"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[21,22,23,24,25],"surfaceOp":"append"} -{"type":"step/end","seq":27,"time":1785730453668,"data":{"turn":1,"step":2}} -{"type":"turn/end","seq":28,"time":1785730453668,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":2,"time":1786357530605,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357530605,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"}]}} +{"type":"turn/start","seq":4,"time":1786357530605,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":5,"time":1786357530605,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"step/start","seq":6,"time":1786357530633,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":7,"time":1785730453639,"data":{"content":[{"type":"text","text":"Call the report tool once with output exactly CHILD_REPORT_OK, then stop."}],"source":{"kind":"user"},"role":"user","id":"9045ac78-393a-4f24-b20d-8999286dd6ce"},"surfaceOp":"append"} +{"type":"user/message","seq":8,"time":1786357530633,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"1677b901-cce7-461a-8b2a-7f119dd9d845"},"surfaceOp":"append"} +{"type":"session/title","seq":9,"time":1786357530633,"data":{"title":"Call the report tool once","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":10,"time":1785730453639,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":11,"time":1785730453639,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":12,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":13,"time":1785594881546,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"call_report_1","name":"report","argumentsDelta":"{\"output\": \"CHILD_REPORT_OK\"}"}}} +{"type":"assistant/chunk","seq":14,"time":1789000001010,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}}}} +{"type":"assistant/chunk","seq":15,"time":1789000001011,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":16,"time":1785730453647,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":17,"time":1785730453647,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c9e50afb-b732-41ab-b0fc-8e98948ad9ec"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[12,13,14,15,16],"surfaceOp":"append"} +{"type":"tool/call","seq":18,"time":1785730453647,"data":{"turn":1,"step":1,"callId":"call_report_1","name":"report","arguments":"{\"output\": \"CHILD_REPORT_OK\"}"}} +{"type":"tool/result","seq":19,"time":1785730453654,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_report_1"},"content":[{"type":"tool-result","toolCallId":"call_report_1","content":[{"type":"text","text":"report accepted by the agent that started you as message 1f4b61e2-6c6d-4db8-836b-ac5760c5e484"}],"isError":false}],"role":"user","id":"cee5f084-bfab-423d-b8bc-1b1b7d88d4fa"}},"sourceEventSeqs":[18],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":1785730453654,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":21,"time":1785730453664,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":22,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":23,"time":1785594881567,"data":{"turn":1,"step":2,"chunk":{"type":"text-delta","index":0,"text":"Reported."}}} +{"type":"assistant/chunk","seq":24,"time":1789000001020,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"Reported."}}}} +{"type":"assistant/chunk","seq":25,"time":1789000001021,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":10,"outputTokens":5}}}} +{"type":"assistant/chunk","seq":26,"time":1785730453668,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":27,"time":1785730453668,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"text","text":"Reported."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"96784835-2d0f-4d00-aef5-ee3a14820dd1"},"usage":{"inputTokens":10,"outputTokens":5}},"sourceEventSeqs":[22,23,24,25,26],"surfaceOp":"append"} +{"type":"step/end","seq":28,"time":1785730453668,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":29,"time":1785730453668,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl index db5cd4a77f..eb36af2399 100644 --- a/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-report/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821411429,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1785730453591,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785730453591,"data":{"content":[{"type":"text","text":"Follow these steps exactly, then stop. 1. Call the subagent tool once with run_in_background set to true, description 'Report a finding', and prompt 'Call the report tool once with output exactly CHILD_REPORT_OK, then stop.'. 2. Reply with the single word STARTED. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"5cf78378-e004-4fd5-af4f-cef3b7e190ad"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730453592,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"77141070-eb99-4ec0-908d-646c387982f6"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730453592,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"d1a851a3-604f-4a42-8e5f-4e480857a3b4"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730453592,"data":{"title":"Follow these steps exactly, then","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785730453592,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730453593,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 7fa229c2e3..5eb0455932 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -1,22 +1,23 @@ {"type":"session","version":0,"id":"ea339828-7885-42e1-9083-4355e6f1708d","createdAt":1783352120855,"cwd":"{{cwd}}","parentSession":"5138ed0d-e86e-4a7d-b75b-803307e92b17","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498793648,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"}]}} -{"type":"turn/start","seq":1,"time":1785821404007,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821404007,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821404020,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply with CHILD_OK"}} -{"type":"step/start","seq":4,"time":1785730446720,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730446720,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730446720,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"1b537017-6493-4f52-8504-01a7384e8cc6"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730446720,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730446720,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730446721,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783352121663,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783352121664,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,27,0,0,29,0,0,27,0,0,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":28,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":29,"time0":1783352121777,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["CH","ILD","_OK"]}} -{"type":"assistant/chunk","seq":32,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."}}}} -{"type":"assistant/chunk","seq":33,"time":1785498793670,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} -{"type":"assistant/chunk","seq":34,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}}}} -{"type":"assistant/chunk","seq":35,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":36,"time":1785730446727,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"16118fc6-2262-476e-9a4a-4b533cff09bc"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35],"surfaceOp":"append"} -{"type":"step/end","seq":37,"time":1785730446727,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":38,"time":1785730446727,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357520283,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357520283,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"}]}} +{"type":"turn/start","seq":2,"time":1786357520283,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357520283,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357520300,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Reply with CHILD_OK"}} +{"type":"step/start","seq":5,"time":1786357520303,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730446720,"data":{"content":[{"type":"text","text":"Reply with exactly the word CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"54ed23d6-e960-4f36-b192-cf06e1618ea6"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357520303,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"24630f5a-f790-469f-96a6-cf234ded3759"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357520303,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730446720,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730446721,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783352121663,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783352121664,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,0,27,0,0,29,0,0,27,0,0,0,0,1],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," the"," word"," CH","ILD","_OK"," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":29,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":30,"time0":1783352121777,"data":{"turn":1,"step":1,"index":1,"dt":[0,0],"texts":["CH","ILD","_OK"]}} +{"type":"assistant/chunk","seq":33,"time":1783352121777,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."}}}} +{"type":"assistant/chunk","seq":34,"time":1785498793670,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"CHILD_OK"}}}} +{"type":"assistant/chunk","seq":35,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}}}} +{"type":"assistant/chunk","seq":36,"time":1785730446727,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":37,"time":1785730446727,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly the word CHILD_OK and nothing else."},{"type":"text","text":"CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"16118fc6-2262-476e-9a4a-4b533cff09bc"},"usage":{"inputTokens":48,"outputTokens":21,"cacheReadTokens":2816,"reasoningTokens":17}},"sourceEventSeqs":[11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36],"surfaceOp":"append"} +{"type":"step/end","seq":38,"time":1785730446727,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":39,"time":1785730446727,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl index edf8950dac..0ac1be7454 100644 --- a/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl +++ b/examples/acp-agent/tests/snapshots/subagent-spawn/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821403947,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783352119275,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498793625,"data":{"content":[{"type":"text","text":"Use the subagent tool exactly once to delegate this subtask to a child agent: 'Reply with exactly the word CHILD_OK and nothing else.' After the subagent returns, reply with the single word PARENT_DONE and stop. Do not use the bash tool."}],"source":{"kind":"user"},"role":"user","id":"a9485ebd-2b4a-434a-bc35-afd757ce141b"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730446685,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e40b1354-1856-48c3-a638-1be67af32920"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730446685,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"bfd99a70-ad54-4073-9c0d-8a63711fe34a"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730446685,"data":{"title":"Use the subagent tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498793626,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730446686,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl index 7b77a09f5e..080198e7bc 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.1.jsonl @@ -1,22 +1,23 @@ {"type":"session","version":0,"id":"583a4db2-3350-436c-b4a5-5615fd159052","createdAt":1783600636316,"cwd":"{{cwd}}","parentSession":"3fd7d599-56b1-493a-930d-f1fc5e1556e8","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498800317,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"}]}} -{"type":"turn/start","seq":1,"time":1785821416523,"data":{"turn":1}} -{"type":"agent/inbox/spliced","seq":2,"time":1785821416523,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} -{"type":"subagent/descriptor","seq":3,"time":1785821416542,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} -{"type":"step/start","seq":4,"time":1785730457309,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730457309,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"},"surfaceOp":"append"} -{"type":"user/message","seq":6,"time":1785730457309,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"e076edc0-a2bf-4fc6-aa58-d44bf1e8fd00"},"surfaceOp":"append"} -{"type":"session/title","seq":7,"time":1785730457309,"data":{"title":"Reply with exactly the word","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":8,"time":1785730457310,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":9,"time":1785730457310,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":10,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":11,"time0":1783600638189,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,24,0,0,0,0,29,0,0,0,0,0,34,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]}} -{"type":"assistant/chunk","seq":29,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":30,"time0":1783600638276,"data":{"turn":1,"step":1,"index":1,"dt":[4,0,0],"texts":["WF","_CH","ILD","_OK"]}} -{"type":"assistant/chunk","seq":34,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} -{"type":"assistant/chunk","seq":35,"time":1785498800343,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":36,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} -{"type":"assistant/chunk","seq":37,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":38,"time":1785730457316,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0ddaf3d1-53dc-45df-bc19-54ad72d6d7fb"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37],"surfaceOp":"append"} -{"type":"step/end","seq":39,"time":1785730457316,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":40,"time":1785730457316,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"approval/policy","seq":0,"time":1786357536718,"data":{"policy":"never","source":"delegation"}} +{"type":"agent/inbox/spliced","seq":1,"time":1786357536719,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"}]}} +{"type":"turn/start","seq":2,"time":1786357536719,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":3,"time":1786357536719,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":4,"time":1786357536736,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":5,"time":1786357536738,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":6,"time":1785730457309,"data":{"content":[{"type":"text","text":"Reply with exactly the word WF_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"f0f46771-663a-494a-8d40-6866a5bbe7c9"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":1786357536738,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`).\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"12bbd4dd-4040-4cc7-8acf-e526144f1ee5"},"surfaceOp":"append"} +{"type":"session/title","seq":8,"time":1786357536738,"data":{"title":"Reply with exactly the word","messageSeqs":[6],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":9,"time":1785730457310,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":10,"time":1785730457310,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":11,"time":1783600638189,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":12,"time0":1783600638189,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,0,24,0,0,0,0,29,0,0,0,0,0,34,0,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","WF","_CH","ILD","_OK","\""," and"," nothing"," else","."]}} +{"type":"assistant/chunk","seq":30,"time":1783600638276,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":31,"time0":1783600638276,"data":{"turn":1,"step":1,"index":1,"dt":[4,0,0],"texts":["WF","_CH","ILD","_OK"]}} +{"type":"assistant/chunk","seq":35,"time":1783600638280,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."}}}} +{"type":"assistant/chunk","seq":36,"time":1785498800343,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"WF_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":37,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}}}} +{"type":"assistant/chunk","seq":38,"time":1785730457316,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":39,"time":1785730457316,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"WF_CHILD_OK\" and nothing else."},{"type":"text","text":"WF_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0ddaf3d1-53dc-45df-bc19-54ad72d6d7fb"},"usage":{"inputTokens":17,"outputTokens":23,"cacheReadTokens":3072,"reasoningTokens":18}},"sourceEventSeqs":[11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34,35,36,37,38],"surfaceOp":"append"} +{"type":"step/end","seq":40,"time":1785730457316,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":41,"time":1785730457316,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl index 6ee104dd0c..eff3a129a4 100644 --- a/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl +++ b/examples/acp-agent/tests/snapshots/workflow-run/session.jsonl @@ -4,7 +4,7 @@ {"type":"agent/inbox/spliced","seq":2,"time":1785821416248,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783600631839,"data":{"turn":1,"step":1}} {"type":"user/message","seq":4,"time":1785498800152,"data":{"content":[{"type":"text","text":"Use the workflow tool exactly once, with args omitted, meta set to { \"name\": \"snapshot-flow\", \"description\": \"one child for the snapshot\" }, and this EXACT script body (copy it verbatim):\nphase('Run')\nconst reply = await agent('Reply with exactly the word WF_CHILD_OK and nothing else.')\nreturn { reply }\nAfter the workflow returns, reply with the single word WORKFLOW_DONE and stop. Do not use any other tool."}],"source":{"kind":"user"},"role":"user","id":"5188a9c7-d3ca-4679-b8df-1443e0a0a4df"},"surfaceOp":"append"} -{"type":"user/message","seq":5,"time":1785730457160,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"1c92c213-1d4f-45ad-be50-161f26a23e65"},"surfaceOp":"append"} +{"type":"user/message","seq":5,"time":1785730457160,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"8f2bd7f3-ba01-4448-b00a-0d6e9c868fc3"},"surfaceOp":"append"} {"type":"session/title","seq":6,"time":1785730457160,"data":{"title":"Use the workflow tool exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} {"type":"request/header","seq":7,"time":1785498800153,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":8,"time":1785730457161,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/acp-agent/web.cordis.snapshot.yml b/examples/acp-agent/web.cordis.snapshot.yml index f0da7617b0..facd66f76c 100644 --- a/examples/acp-agent/web.cordis.snapshot.yml +++ b/examples/acp-agent/web.cordis.snapshot.yml @@ -2,7 +2,7 @@ # fixture server stay real (the tool call re-executes the actual HTTP fetch and # markdown rendering); only the model adapter is replaced by replay. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/web.cordis.yml b/examples/acp-agent/web.cordis.yml index 1ed0b3efba..63ce043340 100644 --- a/examples/acp-agent/web.cordis.yml +++ b/examples/acp-agent/web.cordis.yml @@ -4,7 +4,7 @@ # fixture server the scenario prompt fetches — deterministic content, no # external network, in recording and replay alike. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/workspace-context.cordis.snapshot.yml b/examples/acp-agent/workspace-context.cordis.snapshot.yml index 6abaaa3f13..0215128de1 100644 --- a/examples/acp-agent/workspace-context.cordis.snapshot.yml +++ b/examples/acp-agent/workspace-context.cordis.snapshot.yml @@ -2,7 +2,7 @@ # compose across includes, so this applies the scenario config and model swap # directly to the live tree. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/acp-agent/workspace-context.cordis.yml b/examples/acp-agent/workspace-context.cordis.yml index 34562e7324..db9fb85353 100644 --- a/examples/acp-agent/workspace-context.cordis.yml +++ b/examples/acp-agent/workspace-context.cordis.yml @@ -2,7 +2,7 @@ # discovery inside the scenario's temporary cwd. The app config patch replaces # the whole base config, so the base fields are restated verbatim. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/README.i18n.yaml b/examples/headless-agent/README.i18n.yaml index 90dd1c4f2a..e965ffee3a 100644 --- a/examples/headless-agent/README.i18n.yaml +++ b/examples/headless-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/headless-agent/README.md -README.md: f12a56920c79f3a7e257c4e56163f323e4312d11 -README.zh.md: 9e409735f03afc62cd788fa2a5d1afdef0fa6c2a +README.md: 2f854924d4bfd2bf66b3d6f47433136098d7b762 +README.zh.md: 4128645da8a0f8a84b17d819fc614051b68f2a11 diff --git a/examples/headless-agent/README.md b/examples/headless-agent/README.md index f12a56920c..2f854924d4 100644 --- a/examples/headless-agent/README.md +++ b/examples/headless-agent/README.md @@ -10,10 +10,10 @@ This directory owns the replay and real-model test composition for a headless co # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run dsh run "fix the failing test in this workspace" +pnpm dsh --profile headless "fix the failing test in this workspace" ``` -The product command is [`dsh run`](../../apps/cli/README.md): it accepts one nonblank task, creates and persists a fresh session, prints the final assistant text, and exits. The root `demo:headless` script is only an alias of that command. +The product command is [`dsh --profile headless`](../../apps/cli/README.md): it accepts one nonblank task, creates and persists a fresh session, prints the final assistant text, and exits. Snapshot suites run this directory's configuration through [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts), an unexported test-only process that emits canonical session events as JSONL before its result record. That stream is test infrastructure, not a supported CLI output format. Child sessions surface only through parent tool events and results. diff --git a/examples/headless-agent/README.zh.md b/examples/headless-agent/README.zh.md index 9e409735f0..4128645da8 100644 --- a/examples/headless-agent/README.zh.md +++ b/examples/headless-agent/README.zh.md @@ -10,10 +10,10 @@ # repo root .env (gitignored) or exported env: # DEEPSEEK_API_KEY=sk-… # DEEPSEEK_BASE_URL=https://… # optional; defaults to the public API -pnpm run dsh run "fix the failing test in this workspace" +pnpm dsh --profile headless "fix the failing test in this workspace" ``` -产品命令是 [`dsh run`](../../apps/cli/README.md):它接受一项非空任务,创建并持久化新会话,打印最终 assistant 文本,然后退出。根目录的 `demo:headless` 脚本只是该命令的别名。 +产品命令是 [`dsh --profile headless`](../../apps/cli/README.md):它接受一项非空任务,创建并持久化新会话,打印最终 assistant 文本,然后退出。 快照套件通过 [`tests/fixtures/headless-driver.ts`](tests/fixtures/headless-driver.ts) 运行本目录的配置。这个未导出且仅供测试使用的进程会在结果记录之前,以 JSONL 发出规范会话事件。该事件流属于测试基础设施,不是受支持的 CLI(命令行界面)输出格式。子会话只通过父会话的工具事件和结果对外显示。 diff --git a/examples/headless-agent/advanced.cordis.snapshot.yml b/examples/headless-agent/advanced.cordis.snapshot.yml index 881a976e33..6ce18953cc 100644 --- a/examples/headless-agent/advanced.cordis.snapshot.yml +++ b/examples/headless-agent/advanced.cordis.snapshot.yml @@ -8,7 +8,7 @@ # disables the key-requiring DeepSeek adapter and inserts `llm-replay` to serve # recorded JSONL without a key or network. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/advanced.cordis.yml b/examples/headless-agent/advanced.cordis.yml index 09ecec6ea2..ab0bd18d6d 100644 --- a/examples/headless-agent/advanced.cordis.yml +++ b/examples/headless-agent/advanced.cordis.yml @@ -1,6 +1,6 @@ # Add Code Mode and Cordis tools to the headless spawn/workflow stack. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/compaction.cordis.snapshot.yml b/examples/headless-agent/compaction.cordis.snapshot.yml index 13515cf5cc..56431c6e37 100644 --- a/examples/headless-agent/compaction.cordis.snapshot.yml +++ b/examples/headless-agent/compaction.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless context-overflow composition for the assembled compaction snapshot. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/credentials.cordis.snapshot.yml b/examples/headless-agent/credentials.cordis.snapshot.yml index 3a8638089a..7f1cb2b1f2 100644 --- a/examples/headless-agent/credentials.cordis.snapshot.yml +++ b/examples/headless-agent/credentials.cordis.snapshot.yml @@ -3,7 +3,7 @@ # the deepseek-official route still registers — so the prompt fails with the actionable # MISSING_CREDENTIAL guidance this snapshot pins as first-run UX. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/e2b.cordis.yml b/examples/headless-agent/e2b.cordis.yml index 61bdbc8aa7..3c48a397ad 100644 --- a/examples/headless-agent/e2b.cordis.yml +++ b/examples/headless-agent/e2b.cordis.yml @@ -8,7 +8,7 @@ # falls back to /home/user/workspace while Bash and PTY keep targeting the # host path, so every tool call fails with a remote spawn error. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./advanced.cordis.yml patches: diff --git a/examples/headless-agent/goal.cordis.snapshot.yml b/examples/headless-agent/goal.cordis.snapshot.yml index f6eeeb05ec..00c34e272e 100644 --- a/examples/headless-agent/goal.cordis.snapshot.yml +++ b/examples/headless-agent/goal.cordis.snapshot.yml @@ -2,7 +2,7 @@ # a config patch cannot target an entry behind a nested include, then restates # the goal overlay while replacing the live model with keyless replay. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/goal.cordis.yml b/examples/headless-agent/goal.cordis.yml index 8f8cdf9e0b..fe84de7bf5 100644 --- a/examples/headless-agent/goal.cordis.yml +++ b/examples/headless-agent/goal.cordis.yml @@ -1,6 +1,6 @@ # Add the persisted goal domain and its model-facing tools to the real one-shot app. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/pty.cordis.snapshot.yml b/examples/headless-agent/pty.cordis.snapshot.yml index 49f3a95afb..3fb20ff468 100644 --- a/examples/headless-agent/pty.cordis.snapshot.yml +++ b/examples/headless-agent/pty.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless opt-in PTY composition for the headless stream-json snapshot. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/ralph.cordis.snapshot.yml b/examples/headless-agent/ralph.cordis.snapshot.yml index e84bdfed31..87e5619cde 100644 --- a/examples/headless-agent/ralph.cordis.snapshot.yml +++ b/examples/headless-agent/ralph.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Replay counterpart to cordis.yml for the shipped Ralph-loop snapshot. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/retry.cordis.snapshot.yml b/examples/headless-agent/retry.cordis.snapshot.yml index b8fc79d17f..30a67f45f6 100644 --- a/examples/headless-agent/retry.cordis.snapshot.yml +++ b/examples/headless-agent/retry.cordis.snapshot.yml @@ -1,6 +1,6 @@ # Keyless provider-retry composition for the headless stream-json snapshot. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/headless-agent/tests/code-mode.e2e.ts b/examples/headless-agent/tests/code-mode.e2e.ts index ad049ab308..d360f7083d 100644 --- a/examples/headless-agent/tests/code-mode.e2e.ts +++ b/examples/headless-agent/tests/code-mode.e2e.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, HarnessError } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/examples/headless-agent/tests/coding-task.e2e.ts b/examples/headless-agent/tests/coding-task.e2e.ts index 75b10ee2bc..42b87eff8a 100644 --- a/examples/headless-agent/tests/coding-task.e2e.ts +++ b/examples/headless-agent/tests/coding-task.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/examples/headless-agent/tests/compaction.e2e.ts b/examples/headless-agent/tests/compaction.e2e.ts index 35a9a1627e..94dd510b84 100644 --- a/examples/headless-agent/tests/compaction.e2e.ts +++ b/examples/headless-agent/tests/compaction.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts index b0b120ecb2..57cb384138 100644 --- a/examples/headless-agent/tests/fixtures/cli-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/cli-mock-llm.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { CallId, LlmAdapter, diff --git a/examples/headless-agent/tests/fixtures/cli.cordis.yml b/examples/headless-agent/tests/fixtures/cli.cordis.yml index 21a26bcd73..3106467857 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-fixture - name: './repository-plugin/load.mjs' - - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ../../cordis.yml patches: diff --git a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml index 930363cf37..fab8ae1b7b 100644 --- a/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml +++ b/examples/headless-agent/tests/fixtures/deepseek-defaults.cordis.yml @@ -1,5 +1,5 @@ - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ../../cordis.yml patches: diff --git a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts index d8dc2c2465..9776438079 100644 --- a/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts +++ b/examples/headless-agent/tests/fixtures/goal-domain/seed-goal.ts @@ -1,6 +1,6 @@ /** Test-only Loader plugin that creates a goal at the first real step edge. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-goal' export const name = 'seed-goal' diff --git a/examples/headless-agent/tests/fixtures/headless-driver.ts b/examples/headless-agent/tests/fixtures/headless-driver.ts index 88d7d73b67..8020129bfb 100644 --- a/examples/headless-agent/tests/fixtures/headless-driver.ts +++ b/examples/headless-agent/tests/fixtures/headless-driver.ts @@ -1,7 +1,7 @@ #!/usr/bin/env node /** Snapshot-only Loader driver: stream one fixture turn as canonical JSONL. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { runFixtureTurn } from '@deepseek-ai/dsh-loader-smoke' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/examples/headless-agent/tests/fixtures/dsh-run.cordis.yml b/examples/headless-agent/tests/fixtures/headless-profile.cordis.yml similarity index 100% rename from examples/headless-agent/tests/fixtures/dsh-run.cordis.yml rename to examples/headless-agent/tests/fixtures/headless-profile.cordis.yml 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 deleted file mode 100644 index e24104e79f..0000000000 --- a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin-assets/skills/0/repository-fixture/SKILL.md +++ /dev/null @@ -1,6 +0,0 @@ ---- -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 deleted file mode 100644 index 29aa97c5ec..0000000000 --- a/examples/headless-agent/tests/fixtures/repository-plugin/dsh-plugin.mjs +++ /dev/null @@ -1,19 +0,0 @@ -// Generated by dsh-plugin-prepare. Do not edit. -const manifest = {"name":"headless-repository-fixture","skills":["dsh-plugin-assets/skills/0"]} -// Value mirror: Cordis const enum FiberState.ACTIVE; keep aligned with dsh-repository-plugin source.ts. -const FIBER_ACTIVE = 2 -export const name = "headless-repository-fixture" -export const inject = ["loader","skills"] -async function mount(ctx, plugin, label, config) { - const fiber = ctx.plugin(plugin, config) - await fiber - if (fiber.state !== FIBER_ACTIVE) { - const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined) - throw new Error(`${label} did not activate (waiting for services: ${missing.join(', ') || 'unknown'})`) - } -} -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 mount(ctx, runtime, 'repository Plugin runtime', { baseUrl: import.meta.url, manifest }) -} diff --git a/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs b/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs deleted file mode 100644 index 90f759876a..0000000000 --- a/examples/headless-agent/tests/fixtures/repository-plugin/load.mjs +++ /dev/null @@ -1,13 +0,0 @@ -/** - * 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) -} diff --git a/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs b/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs index 5a2fc5d128..76981fe79c 100644 --- a/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs +++ b/examples/headless-agent/tests/fixtures/retry-snapshot-backend.mjs @@ -46,7 +46,7 @@ export const inject = ['llm'] /** * Register the deterministic provider adapter. - * @param {import('cordis').Context} ctx - plugin context carrying the LLM service. + * @param {import('@deepseek-ai/cordis').Context} ctx - plugin context carrying the LLM service. */ export function apply(ctx) { ctx.llm.registerAdapter(['deepseek-official'], new RetrySnapshotAdapter()) diff --git a/examples/headless-agent/tests/fixtures/semantic-checkpoint-agent.ts b/examples/headless-agent/tests/fixtures/semantic-checkpoint-agent.ts index ef7cf4bafe..f8832da8b3 100644 --- a/examples/headless-agent/tests/fixtures/semantic-checkpoint-agent.ts +++ b/examples/headless-agent/tests/fixtures/semantic-checkpoint-agent.ts @@ -3,7 +3,7 @@ * @module semantic-checkpoint-agent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session' /** Fixture plugin name. */ diff --git a/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts b/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts index f77afe7e0a..a869ab6c9f 100644 --- a/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts +++ b/examples/headless-agent/tests/fixtures/subagent-diagnostic-agent.ts @@ -4,7 +4,7 @@ * @module subagent-diagnostic-agent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session' /** Fixture plugin name. */ diff --git a/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts b/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts index 9cd3e6235f..681793a85b 100644 --- a/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts +++ b/examples/headless-agent/tests/fixtures/subagent-inheritance-agent.ts @@ -3,7 +3,7 @@ * @module subagent-inheritance-agent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session' /** Fixture plugin name. */ diff --git a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml index 27172b40d8..d7be47fbbe 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml +++ b/examples/headless-agent/tests/fixtures/telemetry-otel.cordis.yml @@ -3,7 +3,7 @@ # The redact-rule entry models a deployment mounting its own scrub rule on the # telemetry/record waterfall — the seam itself ships no rules. - id: logger-console - name: '@cordisjs/plugin-logger-console' + name: '@deepseek-ai/cordis-plugin-logger-console' config: colors: false levels: diff --git a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts index 7a2aa7958a..5f205f606e 100644 --- a/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts +++ b/examples/headless-agent/tests/fixtures/telemetry-redact-rule.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' /** * Deployment-style redaction rule for the telemetry e2e: scrubs the fixture diff --git a/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts b/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts index 8cd3155ca7..9689dafbcf 100644 --- a/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts +++ b/examples/headless-agent/tests/fixtures/time-context-mock-llm.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' /** Deterministic one-step adapter for the time-context Loader fixture. */ diff --git a/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts b/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts index 4cdf7d157a..ca1499d897 100644 --- a/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts +++ b/examples/headless-agent/tests/fixtures/workspace-context-resume-agent.ts @@ -3,7 +3,7 @@ * @module workspace-context-resume-agent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-session' /** Fixture plugin name. */ diff --git a/examples/headless-agent/tests/full-loop.e2e.ts b/examples/headless-agent/tests/full-loop.e2e.ts index f4bb8695c9..a8825cc84d 100644 --- a/examples/headless-agent/tests/full-loop.e2e.ts +++ b/examples/headless-agent/tests/full-loop.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/examples/headless-agent/tests/harness.ts b/examples/headless-agent/tests/harness.ts index 9b953256b7..7cf28a0dc0 100644 --- a/examples/headless-agent/tests/harness.ts +++ b/examples/headless-agent/tests/harness.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/examples/headless-agent/tests/headless.snapshot.ts b/examples/headless-agent/tests/headless.snapshot.ts index b09458ec3d..5fc337527e 100644 --- a/examples/headless-agent/tests/headless.snapshot.ts +++ b/examples/headless-agent/tests/headless.snapshot.ts @@ -52,9 +52,9 @@ const dshBinScript = fileURLToPath(new URL('../../../apps/cli/src/bin.ts', impor const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) const reasoningConfigPath = fileURLToPath(new URL('./fixtures/cli.cordis.yml', import.meta.url)) const deepseekDefaultsConfigPath = fileURLToPath(new URL('./fixtures/deepseek-defaults.cordis.yml', import.meta.url)) -const dshRunOverlayPath = fileURLToPath(new URL('./fixtures/dsh-run.cordis.yml', import.meta.url)) -const dshRunSessionExpected = join(snapshotsDir, 'dsh-run', 'session.expected.jsonl') -const dshRunFailureExpected = join(snapshotsDir, 'dsh-run', 'stderr.expected.txt') +const headlessOverlayPath = fileURLToPath(new URL('./fixtures/headless-profile.cordis.yml', import.meta.url)) +const headlessSessionExpected = join(snapshotsDir, 'headless-profile', 'session.expected.jsonl') +const headlessFailureExpected = join(snapshotsDir, 'headless-profile', 'stderr.expected.txt') const cliMockLlmPluginPath = fileURLToPath(new URL('./fixtures/cli-mock-llm.ts', import.meta.url)) const refreshing = process.env.DSH_SNAPSHOT === 'refresh' @@ -217,14 +217,14 @@ async function prepareCliMockFixture(cwd: string): Promise { } describe('headless stream-json snapshots', () => { - it('runs one task through the product dsh run command', async () => { - const task = 'Prove the product dsh run path with one real tool round trip.' + it('runs one task through the product headless profile command', async () => { + const task = 'Prove the product headless profile path with one real tool round trip.' const result = await runLoaderSmoke({ - label: 'product dsh run snapshot', - tempDirPrefix: 'headless-snapshot-dsh-run-', + label: 'product headless profile snapshot', + tempDirPrefix: 'headless-snapshot-profile-', binScript: dshBinScript, - configPath: dshRunOverlayPath, - binArgs: ['run', '--patch', dshRunOverlayPath, task], + configPath: headlessOverlayPath, + binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, task], tsconfigPath, env: { DSH_PERMISSION_MODE: 'danger-full-access', @@ -236,11 +236,11 @@ describe('headless stream-json snapshots', () => { const logs = await persistedLogs(cwd, join(cwd, '.dsh', 'sessions')) expect(logs).toHaveLength(1) const actual = logs[0] - if (actual === undefined) throw new Error('dsh run did not persist its session') + if (actual === undefined) throw new Error('the headless profile did not persist its session') const context = contextFromLogs([actual.content]) const session = scrubRequestHeaders(normalizeSessionLog(actual.content, context)) - if (refreshing) await writeFile(dshRunSessionExpected, session) - expect(session).toBe(await readFile(dshRunSessionExpected, 'utf8')) + if (refreshing) await writeFile(headlessSessionExpected, session) + expect(session).toBe(await readFile(headlessSessionExpected, 'utf8')) expect(session).toContain(task) expect(session).toContain('CLI tool round trip complete: CLI_TOOL_ROUND_TRIP') }, @@ -250,13 +250,13 @@ describe('headless stream-json snapshots', () => { expect(result.stderr).toBe('') }, LOADER_SMOKE_TEST_TIMEOUT_MS) - it('prints a terminal model failure through the product dsh run command', async () => { + it('prints a terminal model failure through the product headless profile command', async () => { const result = await runLoaderSmoke({ - label: 'product dsh run model failure snapshot', - tempDirPrefix: 'headless-snapshot-dsh-run-failure-', + label: 'product headless profile model failure snapshot', + tempDirPrefix: 'headless-snapshot-profile-failure-', binScript: dshBinScript, - configPath: dshRunOverlayPath, - binArgs: ['run', '--patch', dshRunOverlayPath, 'Trigger the keyless model failure.'], + configPath: headlessOverlayPath, + binArgs: ['--profile', 'headless', '--patch', headlessOverlayPath, 'Trigger the keyless model failure.'], tsconfigPath, expectedExitCode: 1, env: { @@ -268,7 +268,7 @@ describe('headless stream-json snapshots', () => { }) expect(result.stdout).toBe('\n') - await expect(result.stderr).toMatchFileSnapshot(dshRunFailureExpected) + await expect(result.stderr).toMatchFileSnapshot(headlessFailureExpected) }, LOADER_SMOKE_TEST_TIMEOUT_MS) it('prints the original Loader activation error through the assembled one-shot app', async () => { diff --git a/examples/headless-agent/tests/keyless-smoke.e2e.ts b/examples/headless-agent/tests/keyless-smoke.e2e.ts index f855c958bf..9eee7cd0ab 100644 --- a/examples/headless-agent/tests/keyless-smoke.e2e.ts +++ b/examples/headless-agent/tests/keyless-smoke.e2e.ts @@ -1,17 +1,10 @@ -import { cp, mkdir, mkdtemp, readFile, readdir, rm, writeFile } from 'node:fs/promises' -import { tmpdir } from 'node:os' +import { readFile, readdir } from 'node:fs/promises' 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, - REPOSITORY_PLUGIN_PREPARE_COMMAND, - REPOSITORY_PLUGIN_PACKAGE_NAME, - prepareDshPlugin, -} from '@deepseek-ai/dsh-repository-plugin' import type { SessionEvent } from '@deepseek-ai/dsh-session' const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) @@ -44,16 +37,6 @@ 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 === 'skill-catalog') - 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({ @@ -63,30 +46,4 @@ describe('headless-agent keyless smoke', () => { expect(String(result?.['output'])).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 stale generated fields. - 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', - scripts: { prepack: REPOSITORY_PLUGIN_PREPARE_COMMAND }, - devDependencies: { [REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' }, - 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/examples/headless-agent/tests/resume.e2e.ts b/examples/headless-agent/tests/resume.e2e.ts index 3875a157d8..6767357b4e 100644 --- a/examples/headless-agent/tests/resume.e2e.ts +++ b/examples/headless-agent/tests/resume.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' import { codingHarness, finalText, SYSTEM_PROMPT, waitForIdle } from './harness.ts' diff --git a/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts b/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts index a8b8f02b9a..474422b4d2 100644 --- a/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts +++ b/examples/headless-agent/tests/semantic-checkpoint.snapshot.ts @@ -1,7 +1,7 @@ import { readFile, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm' diff --git a/examples/headless-agent/tests/session-format-guard.snapshot.ts b/examples/headless-agent/tests/session-format-guard.snapshot.ts new file mode 100644 index 0000000000..ac1b5ae43c --- /dev/null +++ b/examples/headless-agent/tests/session-format-guard.snapshot.ts @@ -0,0 +1,107 @@ +/** + * Assembled-app regression for the session-format refusal surface: resuming a + * log written by a "newer" harness (format version ahead, or an unknown + * required event type) fails loud through the real Loader composition, and the + * error the product user sees names the direction and the raw log path. + * @module session-format-guard-snapshot + */ + +import { join, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' +import SessionStore, { + SESSION_FORMAT_VERSION, + SessionId, + type SessionEvent, + type SessionHeader, +} from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import { describe, expect, it } from 'vitest' + +const fixtureDir = join(dirname(fileURLToPath(import.meta.url)), 'workspace-context-resume-snapshots/offline-edit') +const replayFixture = join(fixtureDir, 'replay.jsonl') +const configPath = fileURLToPath(new URL('../workspace-context-resume.cordis.snapshot.yml', import.meta.url)) +const binScript = fileURLToPath(new URL('./fixtures/headless-driver.ts', import.meta.url)) +const tsconfigPath = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +// The resumed-agent fixture in the shared config resumes exactly this id. +const sessionId = SessionId('workspace-context-resume') + +/** Persist one session with the given header version and events, returning its log path. */ +async function seedSession(root: string, cwd: string, version: number, events: SessionEvent[]): Promise { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(SessionPersistenceJsonl, { root, compression: 'none' }) + const meta: SessionHeader = { version, id: sessionId, createdAt: 1, cwd } + try { + await ctx.sessionPersistence.create(meta) + await ctx.sessionPersistence.append(sessionId, events) + const location = ctx.sessionPersistence.locate(meta) + if (location === undefined) throw new Error('JSONL backend did not locate the seeded session') + return location.path + } finally { + await ctx.fiber.dispose() + } +} + +function closedTurn(): SessionEvent[] { + return [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ] +} + +describe('session format guard through the assembled app', () => { + it('refuses to resume a newer-format log, naming the upgrade direction and the raw log path', async () => { + let sessionPath = '' + const result = await runLoaderSmoke({ + label: 'newer-format resume refusal', + tempDirPrefix: 'dsh-format-guard-version-', + binScript, + libBinScript: binScript, + configPath, + binArgs: [configPath, 'Try to resume.'], + tsconfigPath, + env: { DSH_SNAPSHOT_FILE: replayFixture }, + expectedExitCode: 1, + prepare: async (runCwd) => { + sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION + 99, closedTurn()) + }, + }) + expect(result.stderr).toContain( + `session "${sessionId}" uses log format v${SESSION_FORMAT_VERSION + 99}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it`, + ) + // macOS reports the temp dir via the /private symlink parent; assert the + // stable path suffix instead of the realpath-dependent prefix. + expect(result.stderr).toContain('(raw log: ') + expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/'))) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) + + it('refuses to resume a log with an unknown required event type', async () => { + let sessionPath = '' + const result = await runLoaderSmoke({ + label: 'unknown-event resume refusal', + tempDirPrefix: 'dsh-format-guard-event-', + binScript, + libBinScript: binScript, + configPath, + binArgs: [configPath, 'Try to resume.'], + tsconfigPath, + env: { DSH_SNAPSHOT_FILE: replayFixture }, + expectedExitCode: 1, + prepare: async (runCwd) => { + sessionPath = await seedSession(join(runCwd, '.sessions'), runCwd, SESSION_FORMAT_VERSION, [ + ...closedTurn(), + { type: 'future/event', seq: 2, time: 3, data: { payload: 1 } } as unknown as SessionEvent, + ]) + }, + }) + expect(result.stderr).toContain( + `session "${sessionId}" contains event type "future/event" (seq 2) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`, + ) + // macOS reports the temp dir via the /private symlink parent; assert the + // stable path suffix instead of the realpath-dependent prefix. + expect(result.stderr).toContain('(raw log: ') + expect(result.stderr).toContain(sessionPath.slice(sessionPath.indexOf('/.sessions/'))) + }, LOADER_SMOKE_TEST_TIMEOUT_MS) +}) diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl index 44cbf0e360..8a1c23140b 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.1.jsonl @@ -1,18 +1,19 @@ {"type":"session","version":0,"id":"22222222-2222-4222-8222-222222222222","createdAt":1783950001000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583877,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"1c70f3f7-2e85-4808-8376-03d4d3bee6e6"}]}} {"type":"turn/start","seq":1,"time":1785821454445,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454445,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454466,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} {"type":"step/start","seq":4,"time":1785730501506,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"fc62f9e7-b8f6-441f-9ee8-17f1f9e4feca"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730501506,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":12,"time":1785498583897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":13,"time":1785730501507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cac680cf-1d70-4fb2-91a3-da1e3a317d2e"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785730501507,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":16,"time":1785730501507,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":5,"time":1785730501506,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"1c70f3f7-2e85-4808-8376-03d4d3bee6e6"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786358103673,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"c9a305d4-add2-453e-8789-4e5c127725f7"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786358103673,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498583897,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730501507,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":12,"time":1783957884564,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":13,"time":1785498583897,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":1785730501507,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":1785730501507,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"c5d4e091-9632-4535-af35-097bc74abdd3"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1785730501507,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":1785730501507,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl index 6988595618..8882bda5af 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.2.jsonl @@ -1,18 +1,19 @@ {"type":"session","version":0,"id":"33333333-3333-4333-8333-333333333333","createdAt":1783950002000,"cwd":"{{cwd}}","parentSession":"11111111-1111-4111-8111-111111111111","origin":"subagent","delegationDepth":1} -{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498584048,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"12fb8b24-9214-4e43-b3d3-47f2af3531f1"}]}} {"type":"turn/start","seq":1,"time":1785821454599,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454599,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"subagent/descriptor","seq":3,"time":1785821454618,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} {"type":"step/start","seq":4,"time":1785730501645,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"093bfc20-c6fc-4573-b172-2c6ca40c188b"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730501645,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":12,"time":1785498584067,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":13,"time":1785730501646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"2b31dae5-8939-44e1-bbcd-9f64aa637d76"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} -{"type":"step/end","seq":15,"time":1785730501646,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":16,"time":1785730501646,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":5,"time":1785730501645,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"12fb8b24-9214-4e43-b3d3-47f2af3531f1"},"surfaceOp":"append"} +{"type":"user/message","seq":6,"time":1786358103827,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"14cf8f47-3a7a-4857-a548-02fe407683fb"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786358103827,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498584067,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730501646,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":11,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":12,"time":1783957884701,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":13,"time":1785498584067,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":14,"time":1785730501646,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":15,"time":1785730501646,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"bb9c39a9-3239-4ee1-939a-bab0046e3028"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[10,11,12,13,14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":1785730501646,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":17,"time":1785730501646,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl index 646110b6d9..8f0a211969 100644 --- a/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl +++ b/examples/headless-agent/tests/snapshots/advanced-toolchain/session.jsonl @@ -1,20 +1,20 @@ {"type":"session","version":0,"id":"11111111-1111-4111-8111-111111111111","createdAt":1783950000000,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"}]}} +{"type":"agent/inbox/spliced","seq":0,"time":1785498583746,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"2b5f9025-324a-4c76-b883-4af3b5c3060a"}]}} {"type":"turn/start","seq":1,"time":1785821454304,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":2,"time":1785821454304,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":1783957884486,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"d2f4f71c-78bc-4a22-908d-c08fbb3ab9ef"},"surfaceOp":"append"} +{"type":"user/message","seq":4,"time":1785498583779,"data":{"content":[{"type":"text","text":"Run this advanced flow exactly once: try a no-op temporary Cordis Plugin named snapshot-marker; use run_code to inspect the live temporary Plugins through tools.cordis_inspect; delegate once to a direct spawn child; run one workflow that delegates to another spawn child; stop dyn-1; then reply with exactly ADVANCED_HEADLESS_OK."}],"source":{"kind":"user"},"role":"user","id":"2b5f9025-324a-4c76-b883-4af3b5c3060a"},"surfaceOp":"append"} {"type":"session/title","seq":5,"time":1785498583779,"data":{"title":"Run this advanced flow exactly","messageSeqs":[4],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} +{"type":"request/header","seq":6,"time":1785498583782,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"You are an AI agent powered by the DeepSeek Harness SDK.\n\nYou are headless-agent, a coding assistant powered by the deepseek-v4-flash model. Your working directory is {{cwd}}.\n\nVerify your work by running the code or tests. Keep answers brief and factual.\n\n\nUse the read tool — not shell commands like cat — to inspect text files. Results include line numbers. Use offset and limit to continue reading large files.\n\nUse the write tool to create files or completely replace file contents. Existing files are overwritten, so read an existing file first (the default fs-policy requires it) and prefer edit for targeted changes.\n\nUse the edit tool for targeted changes to existing UTF-8 text files. It replaces literal old_string with new_string; by default old_string must appear exactly once. If old_string appears multiple times, provide a more specific old_string or set replace_all to true. Read the file first (the default fs-policy requires it), unless you just created or edited it in this session.\n\nCheck the [exit code: N] marker on every bash result; investigate failures before moving on.\n\nTrack every background task id you start. You are notified in-session when a task finishes — do not busy-poll or sleep on one; keep working on independent steps and do not duplicate a running task's work. Before giving a final answer, collect every still-relevant task with task_output (set wait: true only when you are genuinely blocked on it), and task_kill tasks that stopped mattering.\n\nUse the workflow tool ONLY when the user explicitly asks for a workflow or for large multi-agent orchestration: you write a JavaScript script (the tool description documents the exact format) that fans work out across many subagents with phases and structured results. For one or two delegations, prefer plain subagent calls.\n\nUse the ralph tool ONLY when the direct human explicitly asks for a Ralph loop or fresh-agent iterative execution. Each Ralph round starts a fresh child with no conversation seed and uses the shared workspace as durable memory. Completion and blockers are worker reports, not independent evaluation. Use same-session goal tools for ordinary long-running objectives, and plain subagents or workflows for bounded delegation and fan-out.\n\n## Writing code for run_code\n\nPass `run_code` the body of an async TypeScript function (erasable syntax only — no `enum` or namespaces; type annotations are advisory, the code runs type-stripped). Inside the program:\n\n- Call tools as `await tools.name(args)` — quoted access for exotic names: `tools[\"my-tool\"](args)`. Every call resolves to the tool's typed canonical JSON value. Tool arguments must be lossless JSON.\n- A FAILED tool call rejects with `ToolCallError`, whose `toolName` identifies the failed tool and whose `message` is human-readable — `try/catch` it to handle and continue.\n- Independent read-only calls MAY overlap under `Promise.all` (safe calls run concurrently; mutating calls run alone, in submission order). Sequence dependent work with `await`.\n- Emit results with `return` and/or `console.log(...)`. ONLY what you print or return comes back to you — intermediate tool results never enter the conversation, so extract just what you need.\n\nThe available tools:\n\n```ts\ntype JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue }\n\ninterface ToolArgsMap {\n /** Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`. */\n bash: {\n /** The bash command to execute. */\n command: string;\n /** Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\". */\n description: string;\n /** Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry. */\n timeoutMs?: number;\n /** Working directory for this command. Defaults to the session workspace; a relative path is resolved against it. */\n workdir?: string;\n /** Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies. */\n run_in_background?: boolean;\n } & Record;\n /** Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc. */\n cordis_inspect: {\n /** Limit the report to one section. Omit for all sections. */\n what?: \"services\" | \"plugins\" | \"tools\" | \"temporary\" | \"api\" | \"events\";\n /** Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\". */\n name?: string;\n } & Record;\n /** Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime. */\n cordis_mount: {\n /** JavaScript body returning a temporary Plugin; evaluated now and saved nowhere. */\n code: string;\n } & Record;\n /** Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins. */\n cordis_unmount: {\n /** The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart. */\n id: string;\n } & Record;\n /** Edit an existing UTF-8 text file by replacing literal text. */\n edit: {\n /** Path to edit, resolved by the filesystem backend. */\n file_path: string;\n /** Literal text to replace. Must match exactly. */\n old_string: string;\n /** Literal replacement text. Use an empty string to delete the match. */\n new_string: string;\n /** Replace all matches. Defaults to false; when false, old_string must appear exactly once. */\n replace_all?: boolean;\n } & Record;\n /** Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op. */\n interrupt_agent: {\n /** The agent id of the running agent to interrupt. */\n agent_id: string;\n } & Record;\n /** Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools. */\n ralph: {\n /** The immutable completion objective for every fresh Ralph round. */\n objective: string;\n /** Optional positive safe-integer round cap, bounded by the deployment ceiling. */\n maxRounds?: number;\n } & Record;\n /** Read a UTF-8 text file and return line-numbered content. */\n read: {\n /** Path to read, resolved by the filesystem backend. */\n file_path: string;\n /** 1-based first line to return. Defaults to 1. */\n offset?: number;\n /** Maximum number of lines to return. Defaults to 2000. */\n limit?: number;\n } & Record;\n /** Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered. */\n send_message: {\n /** The subagent id returned when the background subagent was started. */\n subagent_id: string;\n /** The message to deliver to the subagent. */\n message: string;\n } & Record;\n /** Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill. */\n skill: {\n /** The exact skill name from the available skills list. */\n name: string;\n } & Record;\n /** Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work. */\n subagent_fork: {\n /** A short (3-5 word) description of the delegated task, for display. */\n description: string;\n /** The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new. */\n prompt: string;\n /** Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message. */\n run_in_background?: boolean;\n } & Record;\n /** Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops. */\n task_kill: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Optional short reason, recorded in the log and forwarded to the task. */\n reason?: string;\n } & Record;\n /** List your background tasks (running and finished) with their ids, kinds, and statuses. */\n task_list: Record;\n /** Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap. */\n task_output: {\n /** Task id returned by the tool that started the background work. */\n task_id: string;\n /** Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive. */\n wait?: boolean;\n /** Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum. */\n timeout_ms?: number;\n } & Record;\n /** Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished). */\n todo_write: {\n /** The COMPLETE task list, replacing any previous list. */\n todos: ({\n /** What the task is — a short imperative line. */\n content: string;\n /** pending (not started) | in_progress (now) | completed (done). */\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n } & Record;\n /** Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn. The workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result. Script-body hooks: - `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly. - `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages. - `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`. - `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim. Misused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`. Constraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes. */\n workflow: {\n /** The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `). */\n script: string;\n /** The workflow identity block (plain JSON — never code). */\n meta: {\n /** Short kebab-case workflow name. */\n name: string;\n /** One-line description of what the workflow does. */\n description: string;\n /** Optional guidance on when this workflow applies. */\n whenToUse?: string;\n /** Optional phase declarations matched by phase() calls. */\n phases?: ({\n /** The phase title phase() calls match by exact string. */\n title: string;\n /** Optional one-line description of the phase. */\n detail?: string;\n /** Optional provider override this phase is expected to use. */\n provider?: string;\n /** Optional model override this phase is expected to use. */\n model?: string;\n } & Record)[];\n } & Record;\n /** Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}). */\n args?: Record;\n } & Record;\n /** Create or fully replace a UTF-8 text file. */\n write: {\n /** Path to write, resolved by the filesystem backend. */\n file_path: string;\n /** Full UTF-8 text content to write. */\n content: string;\n } & Record;\n}\n\ninterface ToolOutputMap {\n bash: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"foreground\";\n exitCode: number | null;\n signal: string | null;\n timedOut: boolean;\n aborted: boolean;\n timeoutMs: number;\n stdout: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n stderr: {\n text: string;\n truncated: boolean;\n spillPath?: string;\n };\n sandbox?: {\n mode: string;\n denied: boolean;\n enforcement?: string;\n runnerFailed?: boolean;\n };\n };\n cordis_inspect: string;\n cordis_mount: {\n id: string;\n pluginName: string;\n state: \"pending\" | \"loading\" | \"active\" | \"failed\" | \"disposed\" | \"unloading\";\n provides: string[];\n waitingFor: string[];\n };\n cordis_unmount: {\n id: string;\n pluginName: string;\n };\n edit: {\n path: string;\n before: string;\n after: string;\n };\n interrupt_agent: {\n accepted: boolean;\n };\n ralph: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n read: {\n path: string;\n offset: number;\n lines: {\n number: number;\n text: string;\n }[];\n totalLines: number;\n };\n send_message: {\n messageId: string;\n };\n skill: {\n name: string;\n provider: string;\n resourceBase?: {\n kind: \"directory\";\n path: string;\n } | {\n kind: \"url\";\n url: string;\n } | {\n kind: \"opaque\";\n description: string;\n };\n content: string;\n };\n subagent: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n subagent_fork: {\n kind: \"background\";\n taskId: string;\n } | {\n kind: \"continuable\";\n subagentId: string;\n } | {\n kind: \"foreground\";\n runId: string;\n output: JsonValue[];\n };\n task_kill: {\n outcome: \"cancellation-requested\" | \"already-finished\";\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n task_list: ({\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n })[];\n task_output: {\n text: string;\n task: {\n id: string;\n kind: string;\n label: string;\n status: \"running\" | \"stopping\" | \"completed\" | \"killed\" | \"failed\";\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n };\n };\n todo_write: {\n todos: ({\n content: string;\n status: \"pending\" | \"in_progress\" | \"completed\";\n })[];\n counts: {\n pending: number;\n inProgress: number;\n completed: number;\n };\n };\n workflow: {\n runId: string;\n agentsStarted: number;\n result: JsonValue;\n };\n write: {\n path: string;\n operation: \"create\" | \"update\";\n before: string | null;\n after: string;\n };\n}\n\ntype ToolName = keyof ToolOutputMap\n\ndeclare class ToolCallError extends Error {\n readonly name: \"ToolCallError\";\n readonly toolName: ToolName;\n}\n\ndeclare const tools: {\n [K in ToolName]: (args: ToolArgsMap[K]) => Promise;\n}\n```","tools":[{"name":"bash","description":"Execute a bash command (`bash -c`) and return its stdout/stderr. Each call runs in a fresh shell: no state (cwd, variables, functions) persists between calls — pass `workdir` instead of using `cd`. Non-zero exits are reported as `[exit code: N]`. Current harness environment facts are exposed through managed `$DSH_*` variables; inspect them when needed. Commands may run under a file sandbox; a blocked file operation is reported as `[sandbox: file access denied under mode]` — a policy denial, not a bug in the command; do not retry another way. Long output is truncated to its tail; the full output is saved to a file whose path is reported when available. Set `run_in_background: true` for long-running commands: the call returns a task id immediately; read its output with `task_output` and stop it with `task_kill`.","parameters":{"type":"object","properties":{"command":{"type":"string","description":"The bash command to execute."},"description":{"type":"string","description":"Clear, concise description of what this command does in active voice, 5-10 words (shown in the UI). Examples: \"ls\" → \"List files in current directory\"; \"git status\" → \"Show working tree status\"; \"npm install\" → \"Install package dependencies\"."},"timeoutMs":{"type":"number","description":"Timeout in milliseconds. The executor applies its configured default and cap, and kills the command on expiry."},"workdir":{"type":"string","description":"Working directory for this command. Defaults to the session workspace; a relative path is resolved against it."},"run_in_background":{"type":"boolean","description":"Run in the background and return a task id immediately (collect with task_output, stop with task_kill). No timeout applies."}},"required":["command","description"]}},{"name":"cordis_inspect","description":"Inspect the live Cordis runtime in the current DSH process. Read-only. Sections: `services` (every provided ctx service and the plugin fiber that owns it), `plugins` (all live plugin fibers with their lifecycle states), `tools` (the model-facing tools currently registered, i.e. what you can call), `temporary` (only temporary Plugins created by cordis_mount: id, name, state, provided services, awaited services, and lifetime), `api` (method signatures AND argument/return type shapes for every LIVE service — read this before writing plugin code that calls a service), `events` (every harness event with its dispatch mode and exact signature — pick listener targets here). Temporary Plugins exist only in memory, remain active across later turns, and disappear after cordis_unmount, toolset unload, or DSH restart; they are not restored automatically. The `temporary` section is a subset of `plugins`. Omit `what` to get all six sections. With `what:\"api\"` or `what:\"events\"`, pass an exact `name` to narrow to one service/event and include its original source JSDoc.","parameters":{"type":"object","properties":{"what":{"type":"string","description":"Limit the report to one section. Omit for all sections.","enum":["services","plugins","tools","temporary","api","events"]},"name":{"type":"string","description":"Exact service key or event name whose original JSDoc to include; valid only with what:\"api\" or what:\"events\"."}}}},{"name":"cordis_mount","description":"Mount a temporary Cordis Plugin in the current DSH process. This creates an in-memory runtime Plugin, not an installed or configured Plugin. It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. `code` runs now as the body of an async JavaScript function in an isolated sandbox and MUST `return` a plugin. Two forms: FUNCTION form `return (ctx) => { … }` — declares no inject, so it can register tools, listen to events, and provide services, but reaching ANY service (e.g. ctx.bash) throws; use it only when you need no services. OBJECT form `return { name?, inject: ['bash', 'llm', …], apply(ctx) { … } }` — declares dependencies, and cordis activates the plugin only after the services exist; PREFER this form. You may reach ONLY the services you list in inject: an undeclared service throws even if it exists, because an undeclared dependency would not be cleaned up if its provider is unmounted. BEFORE calling a service from your code, read cordis_inspect what:\"api\" — it lists method signatures AND the type shapes of their arguments/returns (do not guess a field's type; e.g. a bash run's stdout is an object, not a string). Inside `apply`, use the standard cordis API: `ctx.on(event, listener)` to observe events (see cordis_inspect what:\"events\"), or call `harness.registerTool(ctx, harness.defineTool({ name, description, parameters: { text: { type: 'string', required: true } }, output: { schema: { type: 'string' }, render(_args, value) { return [{ type: 'text', text: value }] } }, async execute(args) { return args.text } }))` to give yourself a new tool — it becomes callable on your NEXT step. Tool parameters: each key IS a property — { type: 'string'|'number'|'integer'|'boolean'|'null'|'object'|'array'|'json', required?: true, description?, enum?, const?, items?, properties? }; every direct DSL object declares additionalProperties: true|false, and oneOf: [schema, schema, ...] replaces type for an exact-one union. A raw JSON-Schema { type: 'object', properties, required?: […] } wrapper is also accepted with open-by-default objects. A tool's `execute` MUST return the lossless JSON value declared by `output.schema`; `output.render(args, value)` separately returns Native/model content blocks. Temporary Plugins can COMPOSE: one Plugin may `ctx.provide('name', value)` a service and another may declare `inject: ['name']` to consume it — the consumer stays pending until the provider exists and returns to pending when the provider is unmounted. Everything registered inside `apply` is cleaned up automatically by cordis_unmount. Sandbox globals: `console` (tagged `[cordis:]`, writes through to the harness terminal), `harness.defineTool`, `harness.registerTool`, `btoa`, `atob`, `TextEncoder`, `TextDecoder`. Node APIs are DISABLED — do filesystem/network/timer work through the cordis services, never Node built-ins: `require`, `setTimeout`/`setInterval`, and `fetch` throw redirect errors; `process` and `Buffer` are undefined. Instead use inject: ['fs'] + ctx.fs for files, inject: ['web'] + ctx.web for HTTP, inject: ['bash'] + ctx.bash for processes, and inject: ['timer'] + ctx.setTimeout/ctx.setInterval for timing (fiber effects, auto-cleaned when unmounted) — cordis_inspect what:\"api\" shows what THIS runtime provides. Write PLAIN JavaScript, not TypeScript (no `as`, no type annotations). Cautions: (1) waterfall events (e.g. tools/pre-execute) hand the listener a trailing `next` callback which MUST be called — returning without `next()` SHORT-CIRCUITS the call; prefer plain notification events unless you intend to intercept. (2) Never await something that only resolves after the current turn (your code runs INSIDE a tool call of that turn — it would deadlock). (3) Your `ctx` is a restricted façade: you can register tools, observe events, provide/consume services, and use timers, but framework internals (ctx.root, ctx.fiber, ctx.extend, ctx.plugin, …) are withheld. It is not a security boundary though — the services you inject (e.g. ctx.bash) reach the real runtime.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"JavaScript body returning a temporary Plugin; evaluated now and saved nowhere."}},"required":["code"]}},{"name":"cordis_unmount","description":"Unmount a current-process temporary Plugin created by cordis_mount. Waits for its tools, listeners, services, timers, and other owned effects to clean up completely. Only dyn-N temporary ids are accepted; this cannot remove Loader, configured, or installed Plugins.","parameters":{"type":"object","properties":{"id":{"type":"string","description":"The temporary Plugin id returned by cordis_mount (for example \"dyn-1\"); valid only in this process and invalid after unmount or restart."}},"required":["id"]}},{"name":"edit","description":"Edit an existing UTF-8 text file by replacing literal text.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to edit, resolved by the filesystem backend."},"old_string":{"type":"string","description":"Literal text to replace. Must match exactly."},"new_string":{"type":"string","description":"Literal replacement text. Use an empty string to delete the match."},"replace_all":{"type":"boolean","description":"Replace all matches. Defaults to false; when false, old_string must appear exactly once."}},"required":["file_path","old_string","new_string"]}},{"name":"interrupt_agent","description":"Request cancellation of a background agent's current turn by its agent id. The target may be your direct child or a deeper agent created under you. Only the current turn stops: messages already queued for the agent stay parked until a later send_message, agents it started keep running, and the agent itself stays available for follow-ups. This call returns as soon as the stop request is accepted, so the target may keep running briefly; interrupting an agent that already finished is an accepted no-op.","parameters":{"type":"object","properties":{"agent_id":{"type":"string","description":"The agent id of the running agent to interrupt."}},"required":["agent_id"]}},{"name":"ralph","description":"Run a foreground fresh-agent Ralph loop toward one immutable objective. Use only when the direct human explicitly asks for Ralph or fresh-agent iteration. Each round opens a new child with no parent conversation or prior child session; the shared workspace is long-term memory, and only a bounded structured report crosses rounds. The call returns when a worker reports completion or a concrete blocker, or at the round limit. Ordinary long-running same-session work belongs to goal tools.","parameters":{"type":"object","properties":{"objective":{"type":"string","description":"The immutable completion objective for every fresh Ralph round."},"maxRounds":{"type":"number","description":"Optional positive safe-integer round cap, bounded by the deployment ceiling."}},"required":["objective"]}},{"name":"read","description":"Read a UTF-8 text file and return line-numbered content.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to read, resolved by the filesystem backend."},"offset":{"type":"number","description":"1-based first line to return. Defaults to 1."},"limit":{"type":"number","description":"Maximum number of lines to return. Defaults to 2000."}},"required":["file_path"]}},{"name":"run_code","description":"Execute a TypeScript program against the available tools. Write the BODY of an async function (erasable syntax only; top-level `await` and `return` work) and call tools as `await tools.name(args)` per the declarations in the system prompt. Only what you print or return comes back — curate it.","parameters":{"type":"object","properties":{"code":{"type":"string","description":"The program: the body of an async TypeScript function."},"description":{"type":"string","description":"Clear, concise description of what this program does in active voice, 5-10 words (shown in the UI). Examples: \"Count TODO markers across packages\"; \"Read failing test and its fixture\"; \"Rename config key in every cordis.yml\"."}},"required":["code","description"]}},{"name":"send_message","description":"Send a message to a background subagent by its subagent id, continuing the same conversation. It becomes the subagent's next turn: if it is still working, the message waits until its current turn finishes, so it cannot redirect work already underway. This call returns no answer from the subagent — only confirmation that the message was delivered — so use it to give it more work. A failure means the message was NOT delivered.","parameters":{"type":"object","properties":{"subagent_id":{"type":"string","description":"The subagent id returned when the background subagent was started."},"message":{"type":"string","description":"The message to deliver to the subagent."}},"required":["subagent_id","message"]}},{"name":"skill","description":"Load the full instructions for an available skill. Call this with the exact skill name from the session skill catalog before acting on a task that names or clearly matches that skill.","parameters":{"type":"object","properties":{"name":{"type":"string","description":"The exact skill name from the available skills list."}},"required":["name"]}},{"name":"subagent","description":"Delegate a self-contained task to a subagent (a separate agent that works in its own context) and return its final result. Use this to offload focused, independent work — research, a scoped implementation, an analysis — so it does not consume this conversation's context. The subagent runs to completion and you receive only its final answer, not its intermediate steps. Give it a complete, standalone prompt: it does not see this conversation. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The complete, self-contained task for the subagent. It does not share this conversation's context, so include everything it needs."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"subagent_fork","description":"Delegate a task to a subagent that inherits this conversation: a child agent seeded with all completed turns so far (it does not see the current in-flight turn), returning only its final result. Use this when the subtask builds on this conversation's context — a follow-up analysis, a review, a continuation — without consuming this conversation's context for the work itself. You receive only its final answer, not its intermediate steps. Set `run_in_background: true` to start a background subagent that keeps its conversation: you receive only its subagent id, never its result, and it works on its own. Use this for work whose result you do not need returned by this call; `send_message` sends it more work.","parameters":{"type":"object","properties":{"description":{"type":"string","description":"A short (3-5 word) description of the delegated task, for display."},"prompt":{"type":"string","description":"The task for the subagent. It already sees this conversation's completed turns, so build on them freely and state only what is new."},"run_in_background":{"type":"boolean","description":"Run as a background subagent that keeps its conversation and return only its subagent id. This call never returns its result; send it more work with send_message."}},"required":["description","prompt"]}},{"name":"task_kill","description":"Request cancellation of a running background task by task id. Returns immediately; the task settles as killed once its work actually stops.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"reason":{"type":"string","description":"Optional short reason, recorded in the log and forwarded to the task."}},"required":["task_id"]}},{"name":"task_list","description":"List your background tasks (running and finished) with their ids, kinds, and statuses.","parameters":{"type":"object","properties":{}}},{"name":"task_output","description":"Read a background task. Stream tasks return only output since the previous read; final-output tasks return their result after settlement. Every response ends with `[status: ...]`. Reads are non-blocking unless `wait: true`, which waits up to the configured cap.","parameters":{"type":"object","properties":{"task_id":{"type":"string","description":"Task id returned by the tool that started the background work."},"wait":{"type":"boolean","description":"Block until the task reaches a terminal status or the timeout expires. A timed-out wait returns [status: running] and leaves the task alive."},"timeout_ms":{"type":"number","description":"Max wait in milliseconds (only meaningful with wait: true). Defaults to the configured wait timeout; capped by the configured maximum."}},"required":["task_id"]}},{"name":"todo_write","description":"Record and update a structured task list for the current work. Send the ENTIRE list every call — it REPLACES the previous list (there are no partial updates, no per-item edits). Use it to plan multi-step work and show progress: add one todo per concrete step before you start. Mark every todo being actively worked on `in_progress` — several at once when work genuinely runs in parallel (e.g. concurrent subagents or background commands), one for sequential work; while work remains, at least one task should be `in_progress`. Mark a todo `completed` the moment it is done (do not batch completions), and allow no `in_progress` item only once all work is complete. Skip the list for trivial single-step tasks. Statuses: `pending` (not started), `in_progress` (being worked on now), `completed` (finished).","parameters":{"type":"object","properties":{"todos":{"type":"array","description":"The COMPLETE task list, replacing any previous list.","items":{"type":"object","additionalProperties":false,"properties":{"content":{"type":"string","description":"What the task is — a short imperative line."},"status":{"type":"string","description":"pending (not started) | in_progress (now) | completed (done).","enum":["pending","in_progress","completed"]}},"required":["content","status"]}}},"required":["todos"]}},{"name":"workflow","description":"Run a JavaScript workflow script that orchestrates subagents at scale. Use this for work that fans out across many independent pieces — an audit over many files, a migration, multi-angle research, adversarial verification of findings — where you write the orchestration as a script instead of delegating turn by turn.\n\nThe workflow's identity rides the `meta` parameter as JSON: required `name` (short kebab-case) and `description` strings, optional `whenToUse` string and `phases` array (`{title, detail?, provider?, model?}`). The `script` parameter is the plain JavaScript body ONLY (NOT TypeScript, and NO `export const meta` statement — meta is a parameter, not code), running with top-level await; end with `return ` — the value must be JSON-serializable and is this tool's result.\n\nScript-body hooks:\n- `agent(prompt, opts?): Promise` — run one subagent to completion. Without `opts.schema` it resolves to the child's final text; with `opts.schema` (an object-rooted JSON Schema using ONLY type/properties/required/additionalProperties/items/enum/const/oneOf — no pattern/format/numeric bounds) it resolves to the validated object. Resolves `null` when the child fails (filter with `.filter(Boolean)`). Other opts: `label` (display), `phase` (progress group), and independent `provider`/`model` LLM target overrides (either may be provided alone). Anything else (`effort`/`isolation`/`agentType`) is rejected loudly.\n- `pipeline(items, ...stages): Promise` — run each item through the stages independently with NO barrier between stages (prefer this for multi-stage work). Each stage receives `(prev, item, index)`. An ordinary stage throw drops that ITEM to `null` and skips its remaining stages.\n- `parallel(thunks): Promise` — run zero-argument functions concurrently and await ALL of them (a barrier; use only when a stage genuinely needs every prior result together). A throwing thunk resolves to `null`.\n- `phase(title)` — start a progress phase; `log(message)` — narrate progress; `args` — the tool call's `args` input, verbatim.\n\nMisused hooks (bad arguments, unknown options, unsupported schemas, tripped caps) throw errors that ALWAYS kill the script — they never dissolve into a per-item `null`.\n\nConstraints: concurrency and total-agent caps apply; no filesystem, network, timers, or Node.js APIs are provided — the agents do the work, the script only coordinates them. The run executes in the foreground: this call returns when the whole script finishes.","parameters":{"type":"object","properties":{"script":{"type":"string","description":"The plain-JS workflow script body (top-level await allowed; NO `export const meta` statement; end with `return `)."},"meta":{"type":"object","description":"The workflow identity block (plain JSON — never code).","additionalProperties":true,"properties":{"name":{"type":"string","description":"Short kebab-case workflow name."},"description":{"type":"string","description":"One-line description of what the workflow does."},"whenToUse":{"type":"string","description":"Optional guidance on when this workflow applies."},"phases":{"type":"array","description":"Optional phase declarations matched by phase() calls.","items":{"type":"object","additionalProperties":true,"properties":{"title":{"type":"string","description":"The phase title phase() calls match by exact string."},"detail":{"type":"string","description":"Optional one-line description of the phase."},"provider":{"type":"string","description":"Optional provider override this phase is expected to use."},"model":{"type":"string","description":"Optional model override this phase is expected to use."}},"required":["title"]}}},"required":["name","description"]},"args":{"type":"object","description":"Optional JSON input exposed to the script as the `args` global (wrap a bare list as a field, e.g. {\"files\": [...]}).","additionalProperties":true}},"required":["script","meta"]}},{"name":"write","description":"Create or fully replace a UTF-8 text file.","parameters":{"type":"object","properties":{"file_path":{"type":"string","description":"Path to write, resolved by the filesystem backend."},"content":{"type":"string","description":"Full UTF-8 text content to write."}},"required":["file_path","content"]}}]},"reason":"initial"}} {"type":"request/context","seq":7,"time":1785730501403,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} {"type":"assistant/chunk","seq":8,"time":1783950000007,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":9,"time":1783950000008,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}} {"type":"assistant/chunk","seq":10,"time":1783950000009,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}}}} {"type":"assistant/chunk","seq":11,"time":1785498583784,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":12,"time":1785730501404,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"e65c0ebe-8e3d-44c0-833f-68efcbc0acb5"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"assistant/message","seq":13,"time":1785730501404,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"b596133f-aafe-4485-9871-ade1dda23373"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} {"type":"tool/call","seq":14,"time":1785730501404,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\":\"return { name: 'snapshot-marker', apply() {} }\"}"}} -{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"abb8ecee-cb03-4a66-9477-38a52458ab05"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"tool/result","seq":15,"time":1785730501413,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"snapshot-marker\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"99819f4a-5e53-4a6f-92f6-5be96b765bce"}},"sourceEventSeqs":[14],"surfaceOp":"append"} {"type":"step/end","seq":16,"time":1785730501413,"data":{"turn":1,"step":1}} {"type":"step/start","seq":17,"time":1785730501423,"data":{"turn":1,"step":2}} {"type":"assistant/chunk","seq":18,"time":1783950000017,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -22,11 +22,11 @@ {"type":"assistant/chunk","seq":20,"time":1783950000019,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}}}} {"type":"assistant/chunk","seq":21,"time":1785498583804,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":22,"time":1785730501424,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cdc95327-3ce1-49ea-8a92-b17e450cc455"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} +{"type":"assistant/message","seq":23,"time":1785730501424,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6697e967-e6bd-46b2-8574-18aeb914e7c6"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[18,19,20,21,22],"surfaceOp":"append"} {"type":"tool/call","seq":24,"time":1785730501424,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.cordis_inspect({ what: 'temporary' })\", \"description\": \"Run the scripted inspection program\"}"}} {"type":"tool/code-dispatch-start","seq":25,"time":1785730501473,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"}}} {"type":"tool/code-dispatch","seq":26,"time":1785730501474,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"cordis_inspect","arguments":{"what":"temporary"},"isError":false,"content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}]}} -{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"d75c7d03-cbbc-4260-ba40-8c210a3b5bbe"}},"sourceEventSeqs":[24],"surfaceOp":"append"} +{"type":"tool/result","seq":27,"time":1785730501475,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"## Temporary Plugins\n- Temporary Plugin dyn-1: snapshot-marker [running] — provides: none; waiting for: none; lifetime: until unmounted or DSH restarts"}],"isError":false}],"role":"user","id":"831070a8-3dc0-4275-9f11-0476b47b8ef2"}},"sourceEventSeqs":[24],"surfaceOp":"append"} {"type":"step/end","seq":28,"time":1785730501475,"data":{"turn":1,"step":2}} {"type":"step/start","seq":29,"time":1785730501483,"data":{"turn":1,"step":3}} {"type":"assistant/chunk","seq":30,"time":1783950000029,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -34,9 +34,9 @@ {"type":"assistant/chunk","seq":32,"time":1785037378923,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} {"type":"assistant/chunk","seq":33,"time":1785498583869,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":34,"time":1785730501484,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"ba4958e9-231c-437f-a2fc-7a13f392d3ba"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} +{"type":"assistant/message","seq":35,"time":1785730501484,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"cef24cf0-5f9e-4be2-93d8-93a0d89c6e82"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[30,31,32,33,34],"surfaceOp":"append"} {"type":"tool/call","seq":36,"time":1785730501484,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\":\"Check direct child\",\"prompt\":\"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"b9ebb37d-e565-4882-95b0-5343da1d68d8"}},"sourceEventSeqs":[36],"surfaceOp":"append"} +{"type":"tool/result","seq":37,"time":1785730501508,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"d7ea395e-840a-46c4-a143-b6e15cf74114"}},"sourceEventSeqs":[36],"surfaceOp":"append"} {"type":"step/end","seq":38,"time":1785730501508,"data":{"turn":1,"step":3}} {"type":"step/start","seq":39,"time":1785730501521,"data":{"turn":1,"step":4}} {"type":"assistant/chunk","seq":40,"time":1783957884594,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -44,9 +44,9 @@ {"type":"assistant/chunk","seq":42,"time":1785037378946,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}}}} {"type":"assistant/chunk","seq":43,"time":1785498583919,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":44,"time":1785730501522,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"4757f4b9-9bde-488b-a54a-1bdea55dd15f"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} +{"type":"assistant/message","seq":45,"time":1785730501522,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"242be4fa-3293-45de-ab55-a017999f2333"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[40,41,42,43,44],"surfaceOp":"append"} {"type":"tool/call","seq":46,"time":1785730501522,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\":\"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\",\"meta\":{\"name\":\"advanced-headless-snapshot\",\"description\":\"exercise one workflow child through the headless agent\"}}"}} -{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"35baa460-54ff-4fa1-ba9d-66b6661f84e9"}},"sourceEventSeqs":[46],"surfaceOp":"append"} +{"type":"tool/result","seq":47,"time":1785730501647,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-headless-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"ee764f11-8827-4984-acef-ec3c5880f5a0"}},"sourceEventSeqs":[46],"surfaceOp":"append"} {"type":"step/end","seq":48,"time":1785730501648,"data":{"turn":1,"step":4}} {"type":"step/start","seq":49,"time":1785730501660,"data":{"turn":1,"step":5}} {"type":"assistant/chunk","seq":50,"time":1783957884719,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} @@ -54,9 +54,9 @@ {"type":"assistant/chunk","seq":52,"time":1785037379534,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}}}} {"type":"assistant/chunk","seq":53,"time":1785498584085,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":54,"time":1785730501661,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"739166e2-ed48-4df2-a9a5-207f34058030"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} +{"type":"assistant/message","seq":55,"time":1785730501661,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"6c3dab82-c2ed-492a-ab0d-f235b340a6c1"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[50,51,52,53,54],"surfaceOp":"append"} {"type":"tool/call","seq":56,"time":1785730501661,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\":\"dyn-1\"}"}} -{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"98b05c06-cb77-41a9-8310-324bc72fc7a0"}},"sourceEventSeqs":[56],"surfaceOp":"append"} +{"type":"tool/result","seq":57,"time":1785730501668,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"74554b42-640e-4119-a413-ee5ac01e546e"}},"sourceEventSeqs":[56],"surfaceOp":"append"} {"type":"step/end","seq":58,"time":1785730501668,"data":{"turn":1,"step":5}} {"type":"step/start","seq":59,"time":1785730501678,"data":{"turn":1,"step":6}} {"type":"assistant/chunk","seq":60,"time":1783957884720,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} @@ -64,6 +64,6 @@ {"type":"assistant/chunk","seq":62,"time":1785037379541,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_HEADLESS_OK"}}}} {"type":"assistant/chunk","seq":63,"time":1785498584102,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} {"type":"assistant/chunk","seq":64,"time":1785730501679,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"0a4ca8f2-92c1-4dbc-beb8-923b8791c298"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} +{"type":"assistant/message","seq":65,"time":1785730501679,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_HEADLESS_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"1a3bef32-0610-4891-9071-6bdc2e8a8fd2"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[60,61,62,63,64],"surfaceOp":"append"} {"type":"step/end","seq":66,"time":1785730501679,"data":{"turn":1,"step":6}} {"type":"turn/end","seq":67,"time":1785730501679,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl b/examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl similarity index 91% rename from examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl rename to examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl index 260ae241b0..65b6f393a6 100644 --- a/examples/headless-agent/tests/snapshots/dsh-run/session.expected.jsonl +++ b/examples/headless-agent/tests/snapshots/headless-profile/session.expected.jsonl @@ -2,16 +2,16 @@ {"type":"permission/preset","seq":0,"time":0,"data":{"preset":"danger-full-access"}} {"type":"sandbox/mode","seq":1,"time":0,"data":{"mode":"danger-full-access"}} {"type":"approval/policy","seq":2,"time":0,"data":{"policy":"never"}} -{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} +{"type":"agent/inbox/spliced","seq":3,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"}]}} {"type":"turn/start","seq":4,"time":0,"data":{"turn":1}} {"type":"agent/inbox/spliced","seq":5,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":6,"time":0,"data":{"turn":1,"step":1}} -{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product dsh run path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Prove the product headless profile path with one real tool round trip."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"user/message","seq":8,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations.\n\nApproval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: danger-full-access. The DSH file sandbox does not restrict file modifications by available operations."},{"name":"approval:policy","text":"Approval prompts are disabled in this session: actions that require approval are rejected automatically — do not request sandbox escalation (do not set `sandbox_permissions`)."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product dsh run","messageSeqs":[7],"source":{"kind":"fallback"}}} +{"type":"session/title","seq":9,"time":0,"data":{"title":"Prove the product headless profile","messageSeqs":[7],"source":{"kind":"fallback"}}} {"type":"request/header","seq":10,"time":0,"data":{"header":{"config":{"provider":"cli-mock","model":"cli-mock","reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":11,"time":0,"data":{"provider":"cli-mock","model":"cli-mock"}} -{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product dsh run path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} +{"type":"session/title-llm-request","seq":12,"time":0,"data":{"titleProvider":"session-title-first-message-llm","messageSeqs":[7],"route":{"provider":"cli-mock","model":"cli-mock"},"system":"Create a concise title for an AI coding-assistant session from the supplied human messages.\nReturn only the title on one line, **in plain text of natural language**, with no quotes, prefix, explanation, Markdown, XML, or terminal control codes. No code is allowed.\nUse the language of the messages.\nAim for about 5 words in non-CJK languages or 10 CJK characters.","messages":[{"content":[{"type":"text","text":"Generate the session title from this JSON array of human messages:\n[{\"seq\":7,\"text\":\"Prove the product headless profile path with one real tool round trip.\"}]"}],"source":{"kind":"plugin","plugin":"dsh-session-title-llm"},"role":"user","id":"{{sessionId}}"}],"maxTokens":64}} {"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} {"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"cli-smoke-call","name":"bash","argumentsDelta":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}} {"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"cli-smoke-call","name":"bash","arguments":"{\"command\":\"printf CLI_TOOL_ROUND_TRIP\",\"description\":\"Prove the CLI tool round trip.\"}"}}}} diff --git a/examples/headless-agent/tests/snapshots/dsh-run/stderr.expected.txt b/examples/headless-agent/tests/snapshots/headless-profile/stderr.expected.txt similarity index 100% rename from examples/headless-agent/tests/snapshots/dsh-run/stderr.expected.txt rename to examples/headless-agent/tests/snapshots/headless-profile/stderr.expected.txt diff --git a/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts b/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts index 998c9129a5..22a3071d9c 100644 --- a/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts +++ b/examples/headless-agent/tests/subagent-diagnostic.snapshot.ts @@ -7,7 +7,7 @@ import { readFile, readdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl index dbcd59cc5a..1711b58c84 100644 --- a/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl +++ b/examples/headless-agent/tests/subagent-inheritance-snapshots/parent-override/child.expected.jsonl @@ -6,7 +6,7 @@ {"type":"subagent/descriptor","seq":4,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Delegated write probe"}} {"type":"step/start","seq":5,"time":0,"data":{"turn":1,"step":1}} {"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Use the write tool exactly once with file_path set to exactly the relative path inherited.txt and content escaped. If the write is denied, reply with the single word CHILD_DENIED and the denial marker line; do not retry and do not request escalation. If it succeeds, reply CHILD_WROTE."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} -{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} +{"type":"user/message","seq":7,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nCurrent DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"sandbox:policy","text":"Current DSH file policy: read-only. Any available operation enforced by the DSH file sandbox cannot modify files in the standing mode. Do not refuse a required modification from this policy alone: try an available tool normally and follow any denial and escalation guidance it returns."},{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"} {"type":"session/title","seq":8,"time":0,"data":{"title":"Use the write tool exactly","messageSeqs":[6],"source":{"kind":"fallback"}}} {"type":"request/header","seq":9,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} {"type":"request/context","seq":10,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} diff --git a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts index be5be2b357..76fe7b41e6 100644 --- a/examples/headless-agent/tests/subagent-inheritance.snapshot.ts +++ b/examples/headless-agent/tests/subagent-inheritance.snapshot.ts @@ -6,7 +6,7 @@ import { readFile, readdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/examples/headless-agent/tests/todo-write.e2e.ts b/examples/headless-agent/tests/todo-write.e2e.ts index 8b1aa9691e..014f1e3e69 100644 --- a/examples/headless-agent/tests/todo-write.e2e.ts +++ b/examples/headless-agent/tests/todo-write.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { codingHarness, TODO_SYSTEM_PROMPT, waitForIdle } from './harness.ts' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts index afe2ba325a..13d80e9fcc 100644 --- a/examples/headless-agent/tests/workspace-context-resume.snapshot.ts +++ b/examples/headless-agent/tests/workspace-context-resume.snapshot.ts @@ -7,7 +7,7 @@ import { createHash } from 'node:crypto' import { mkdir, readFile, readdir, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { normalizeSessionLog, scrubRequestHeaders, type NormalizeContext } from '@deepseek-ai/dsh-acp-snapshot' import { LOADER_SMOKE_TEST_TIMEOUT_MS, runLoaderSmoke } from '@deepseek-ai/dsh-loader-smoke' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/examples/jsonrpc-agent/README.i18n.yaml b/examples/jsonrpc-agent/README.i18n.yaml index e1c36e959c..04ee95bfab 100644 --- a/examples/jsonrpc-agent/README.i18n.yaml +++ b/examples/jsonrpc-agent/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/jsonrpc-agent/README.md -README.md: bcc1027d2edb30ab374dfa2ed13ad8e6360d923b -README.zh.md: ce255e4dd70bf8c5c6edc51afbe03bb4c66560a0 +README.md: 5f60a64a4888c64e4fd68835f78e4a334ffed263 +README.zh.md: 8d2f9807ff259000b5a6823357f8c41b43bfa434 diff --git a/examples/jsonrpc-agent/README.md b/examples/jsonrpc-agent/README.md index bcc1027d2e..5f60a64a48 100644 --- a/examples/jsonrpc-agent/README.md +++ b/examples/jsonrpc-agent/README.md @@ -21,16 +21,16 @@ The surrounding runtime also loads JSONL session persistence and automatic conte | `DEEPSEEK_BASE_URL` | Host endpoint used by `dsh-llm-deepseek` | | `DSH_CWD` | Agent workspace for bash and filesystem tools | | `DSH_MAX_TOKENS_AS_SUCCESS` | `true` (default) accepts token-limited results; `false` reports them as errors | -| `DSH_SESSION_ROOT` | JSONL trajectory directory | +| `DSH_SESSION_ROOT` | JSONL session directory | | `DSH_SYSTEM_PROMPT` | Deployment-provided coding persona | Pass the config path through the Python SDK's `cordis` option or `DSH_CORDIS_CONFIG`. The bundled executable already carries every plugin named by this file; the target machine does not need Node.js. -## Persistent tools variant +## Minimal variant -[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) is a minimal runnable variant whose model-facing surface is exactly: +[`minimal.cordis.yml`](minimal.cordis.yml) is the complete standalone counterpart of the Web `minimal` preset. It fixes the system prompt and compaction policy, and its model-facing surface is exactly: - owner-scoped persistent `bash` - `str_replace_editor` with `view`, `create`, `str_replace`, and `insert` -It composes the local PTY, filesystem intent policy, and session sandbox policy. +It composes the local PTY, filesystem intent policy, session sandbox policy, and JSONL persistence needed by the bundled runtime. [`minimal.py`](minimal.py) runs it through the Python SDK; the [Python SDK tutorial](../../docs/user/guide/python-sdk.md) uses this configuration to cover setup, session management, and the security boundary. diff --git a/examples/jsonrpc-agent/README.zh.md b/examples/jsonrpc-agent/README.zh.md index ce255e4dd7..8d2f9807ff 100644 --- a/examples/jsonrpc-agent/README.zh.md +++ b/examples/jsonrpc-agent/README.zh.md @@ -21,16 +21,16 @@ | `DEEPSEEK_BASE_URL` | `dsh-llm-deepseek` 使用的宿主端点 | | `DSH_CWD` | bash 和文件系统工具使用的 agent workspace | | `DSH_MAX_TOKENS_AS_SUCCESS` | `true`(默认)接受受 token 上限限制的结果;`false` 将其报告为错误 | -| `DSH_SESSION_ROOT` | JSONL 轨迹目录 | +| `DSH_SESSION_ROOT` | JSONL 会话目录 | | `DSH_SYSTEM_PROMPT` | 由部署提供的编码人格 | 通过 Python SDK 的 `cordis` 选项或 `DSH_CORDIS_CONFIG` 传入配置路径。内置可执行文件已携带此文件中指定的每个插件;目标机器无需 Node.js。 -## 持久化工具变体 +## 极简变体 -[`persistent-tools.cordis.yml`](persistent-tools.cordis.yml) 是一个最小可运行变体,面向模型的能力严格只有: +[`minimal.cordis.yml`](minimal.cordis.yml) 是 Web `minimal` preset 的完整独立版本。它固定系统提示词与压缩策略,面向模型的能力严格只有: - 所有者作用域内持久化的 `bash` - 提供 `view`、`create`、`str_replace` 与 `insert` 的 `str_replace_editor` -它组合了本地 PTY、文件系统意图策略与会话沙箱策略。 +它组合了内置运行时所需的本地 PTY、文件系统意图策略、会话沙箱策略与 JSONL 持久化。[`minimal.py`](minimal.py) 通过 Python SDK 运行该配置;[Python SDK 教程](../../docs/user/guide/python-sdk.md)以此配置介绍设置方式、会话管理与安全边界。 diff --git a/examples/jsonrpc-agent/cordis.snapshot.yml b/examples/jsonrpc-agent/cordis.snapshot.yml index 6c3a6f99e7..23bc8c5402 100644 --- a/examples/jsonrpc-agent/cordis.snapshot.yml +++ b/examples/jsonrpc-agent/cordis.snapshot.yml @@ -8,7 +8,7 @@ # `llm-replay` reads `DSH_SNAPSHOT_FILE` / `DSH_SNAPSHOT_CHILD_FILES` from the # harness. Stdout remains reserved for JSON-RPC frames. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: path: ./cordis.yml patches: diff --git a/examples/jsonrpc-agent/minimal.cordis.yml b/examples/jsonrpc-agent/minimal.cordis.yml new file mode 100644 index 0000000000..a374d1655a --- /dev/null +++ b/examples/jsonrpc-agent/minimal.cordis.yml @@ -0,0 +1,91 @@ +# Complete unattended minimal-agent composition for the Python SDK. The model +# sees one fixed system prompt and only the owner-scoped persistent Bash and +# string-replace editor tools. + +- id: jsonrpc + name: '@deepseek-ai/dsh-jsonrpc' + config: + maxTokensAsSuccess: false + +- id: llm-deepseek + name: '@deepseek-ai/dsh-llm-deepseek' + +- id: sandbox + name: '@deepseek-ai/dsh-sandbox-local' + +- id: sandbox-policy + name: '@deepseek-ai/dsh-sandbox-policy' + config: + mode: danger-full-access + workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() + +- id: subprocess + name: '@deepseek-ai/dsh-subprocess-local' + +- id: pty + name: '@deepseek-ai/dsh-pty' + +- id: pty-local + name: '@deepseek-ai/dsh-pty-local' + config: + timeoutMs: 300000 + +# The sandbox-aware filesystem backend applies the same per-session policy as +# Bash. danger-full-access permits unrestricted workspace behavior while +# keeping one policy boundary for both tools. +- id: fs-sandbox + name: '@deepseek-ai/dsh-fs-sandbox' + config: + cwd: !!js process.env.DSH_CWD ?? process.cwd() + +- id: fs-policy + name: '@deepseek-ai/dsh-fs-policy' + +- id: agent-spine + name: '@deepseek-ai/dsh-agent-spine-demo' + config: + includeHarnessIdentity: false + persona: You are a helpful software engineer assistant. + workspaceContext: false + skills: + enabled: false + toolBash: false + toolTasks: false + +- id: persistent-bash + name: '@deepseek-ai/dsh-tool-bash-persistent' + config: + timeoutMs: 300000 + description: |- + Run commands in a bash shell + * When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. + * You don't have access to the internet via this tool. + * You do have access to a mirror of common linux and python packages via apt and pip. + * State is persistent across command calls and discussions with the user. + * To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. + * Please avoid commands that may produce a very large amount of output. + * Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background. + +- id: str-replace-editor + name: '@deepseek-ai/dsh-tool-str-replace-editor' + config: + maxOutputChars: 16000 + +- id: sessions + name: '@deepseek-ai/dsh-session-persistence-jsonl' + config: + root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' + compression: none + +- id: token-meter + name: '@deepseek-ai/dsh-token-meter' + +- id: compact-basic + name: '@deepseek-ai/dsh-compact-basic' + config: + thresholdRatio: 0.8 + retainTokens: 20480 + summarizationProvider: '' + summarizationModel: '' + maxTokens: 8192 + compactionRetries: 1 diff --git a/examples/jsonrpc-agent/minimal.py b/examples/jsonrpc-agent/minimal.py new file mode 100644 index 0000000000..c82f97c60a --- /dev/null +++ b/examples/jsonrpc-agent/minimal.py @@ -0,0 +1,42 @@ +#!/usr/bin/env python3 +"""Run one minimal-agent turn through the bundled Python SDK runtime.""" + +from __future__ import annotations + +import argparse +from pathlib import Path + +from deepseek_harness import DeepSeekHarness + + +CONFIG = Path(__file__).with_name("minimal.cordis.yml") + + +def main() -> None: + """Parse one task and print the agent's final response.""" + parser = argparse.ArgumentParser() + parser.add_argument("prompt", help="Task for the minimal agent") + parser.add_argument("--workspace", type=Path, default=Path.cwd()) + parser.add_argument("--session-root", type=Path, default=Path(".dsh-sessions")) + parser.add_argument("--session-id") + parser.add_argument("--provider", default="deepseek-official") + parser.add_argument("--model", default="deepseek-v4-flash") + parser.add_argument("--max-tokens", type=int) + args = parser.parse_args() + + workspace = args.workspace.resolve() + session_root = args.session_root.resolve() + with DeepSeekHarness( + provider=args.provider, + model=args.model, + max_tokens=args.max_tokens, + cwd=str(workspace), + session_root=str(session_root), + cordis=str(CONFIG.resolve()), + ) as harness: + result = harness.run(args.prompt, session_id=args.session_id) + print(result.final_response) + + +if __name__ == "__main__": + main() diff --git a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml b/examples/jsonrpc-agent/minimal.snapshot.cordis.yml similarity index 56% rename from examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml rename to examples/jsonrpc-agent/minimal.snapshot.cordis.yml index 498d5467f2..0f26fa6716 100644 --- a/examples/jsonrpc-agent/persistent-tools.snapshot.cordis.yml +++ b/examples/jsonrpc-agent/minimal.snapshot.cordis.yml @@ -1,12 +1,10 @@ -# Keyless replay keeps the persistent-tool composition intact and replaces -# only its live DeepSeek adapter with the fixture-backed provider. The catalog -# below claims the same `deepseek-official` route the agent asks for: an -# unowned route makes the SDK server mount the real adapter, which then demands -# a key this keyless lane has no way to supply. +# Keyless replay keeps the complete minimal composition intact and replaces +# only its live DeepSeek adapter with the fixture-backed provider. The replay +# catalog claims the same route initialized by the SDK. - id: base - name: '@cordisjs/plugin-include' + name: '@deepseek-ai/cordis-plugin-include' config: - path: ./persistent-tools.cordis.yml + path: ./minimal.cordis.yml patches: - id: llm-deepseek name: '@deepseek-ai/dsh-llm-deepseek' diff --git a/examples/jsonrpc-agent/persistent-tools.cordis.yml b/examples/jsonrpc-agent/persistent-tools.cordis.yml deleted file mode 100644 index ebe0a00e61..0000000000 --- a/examples/jsonrpc-agent/persistent-tools.cordis.yml +++ /dev/null @@ -1,59 +0,0 @@ -# Minimal unattended composition for the persistent Bash and string-replace -# editor. It is runnable through the JSON-RPC example runtime and intentionally -# keeps the model-facing surface to exactly these two tools. - -- id: jsonrpc - name: '@deepseek-ai/dsh-jsonrpc' - -- id: llm-deepseek - name: '@deepseek-ai/dsh-llm-deepseek' - config: - -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' - -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.env.DSH_CWD ?? process.cwd() - -- id: subprocess - name: '@deepseek-ai/dsh-subprocess-local' - -- id: pty - name: '@deepseek-ai/dsh-pty' - -- id: pty-local - name: '@deepseek-ai/dsh-pty-local' - -- id: fs-sandbox - name: '@deepseek-ai/dsh-fs-sandbox' - config: - cwd: !!js process.env.DSH_CWD ?? process.cwd() - -- id: fs-policy - name: '@deepseek-ai/dsh-fs-policy' - -- id: agent-spine - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - includeHarnessIdentity: false - persona: 'You are a helpful software engineer assistant.' - workspaceContext: false - skills: - enabled: false - toolBash: false - toolTasks: false - -- id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' - -- id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' - -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT ?? './.sessions' - compression: none diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts index 30f480064e..b693787968 100644 --- a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/child-mock-llm.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { LlmAdapter } from '@deepseek-ai/dsh-llm' diff --git a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts index e5ce753fd8..e0a3664487 100644 --- a/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts +++ b/examples/jsonrpc-agent/tests/fixtures/subagent/subagent-dsh-sdk/mock-delegating-llm.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' diff --git a/examples/jsonrpc-agent/tests/sdk.snapshot.ts b/examples/jsonrpc-agent/tests/sdk.snapshot.ts index 4a29d6eb6a..a45e12b192 100644 --- a/examples/jsonrpc-agent/tests/sdk.snapshot.ts +++ b/examples/jsonrpc-agent/tests/sdk.snapshot.ts @@ -33,11 +33,21 @@ const testsDir = dirOf(import.meta.url) const snapshotsDir = join(testsDir, 'snapshots') const liveConfig = join(testsDir, '..', 'cordis.yml') const replayConfig = join(testsDir, '..', 'cordis.snapshot.yml') -const persistentToolsLiveConfig = join(testsDir, '..', 'persistent-tools.cordis.yml') -const persistentToolsReplayConfig = join(testsDir, '..', 'persistent-tools.snapshot.cordis.yml') +const minimalLiveConfig = join(testsDir, '..', 'minimal.cordis.yml') +const minimalReplayConfig = join(testsDir, '..', 'minimal.snapshot.cordis.yml') const runtimeBin = fileURLToPath(new URL('../../../packages/examples/jsonrpc-demo/src/bin.ts', import.meta.url)) const repoTsconfig = fileURLToPath(new URL('../../../tsconfig.json', import.meta.url)) +const MINIMAL_SYSTEM_PROMPT = 'You are a helpful software engineer assistant.' +const MINIMAL_BASH_DESCRIPTION = `Run commands in a bash shell +* When invoking this tool, the contents of the "command" parameter does NOT need to be XML-escaped. +* You don't have access to the internet via this tool. +* You do have access to a mirror of common linux and python packages via apt and pip. +* State is persistent across command calls and discussions with the user. +* To inspect a particular line range of a file, e.g. lines 10-25, try 'sed -n 10,25p /path/to/the/file'. +* Please avoid commands that may produce a very large amount of output. +* Please run long lived commands in the background, e.g. 'sleep 10 &' or start a server in the background.` + const mode = process.env.DSH_SNAPSHOT ?? 'replay' const recording = mode === 'record' const refreshing = mode === 'refresh' @@ -61,6 +71,10 @@ interface SdkScenario { expectedFiles?: Readonly> /** Assembled model-facing tool names and required argument keys. */ expectedTools?: Readonly> + /** Exact assembled system prompt for the root request. */ + expectedSystem?: string + /** Exact model-facing descriptions for selected tools. */ + expectedToolDescriptions?: Readonly> /** Stable policy-context clauses the real assembled request must include or omit. */ policyContext?: { includes: readonly string[]; excludes: readonly string[] } } @@ -89,9 +103,11 @@ const SCENARIOS: SdkScenario[] = [ prompt: 'Prove that bash state persists. Then create {{cwd}}/note.txt with a tab-indented line, view it, replace that literal tab-indented line, and make the persistent shell exit with code 9.', sessionId: 'persistent-tools-snapshot', children: 0, - configs: { live: persistentToolsLiveConfig, replay: persistentToolsReplayConfig }, + configs: { live: minimalLiveConfig, replay: minimalReplayConfig }, expectedFiles: { 'note.txt': 'target:\n\tnew\n' }, expectedTools: { bash: ['command'], str_replace_editor: ['command', 'path'] }, + expectedSystem: MINIMAL_SYSTEM_PROMPT, + expectedToolDescriptions: { bash: MINIMAL_BASH_DESCRIPTION }, policyContext: { includes: ['Current DSH file policy: danger-full-access.', 'file modifications by available operations'], excludes: ['write and edit tools', 'terminal sessions', 'one-shot bash commands'], @@ -125,16 +141,33 @@ async function persistedLogs(sessionsRoot: string): Promise { interface LoggedRequestHeader { type?: string - data?: { header?: { system?: unknown; tools?: Array<{ name: string; parameters: { required?: string[] } }> } } + data?: { header?: { system?: unknown; tools?: LoggedTool[] } } } -function assembledToolRequirements(log: PersistedLog): Record { +interface LoggedTool { + readonly name: string + readonly description?: unknown + readonly parameters: { readonly required?: string[] } +} + +function assembledTools(log: PersistedLog): LoggedTool[] { const event = log.content.trimEnd().split('\n') .map(line => JSON.parse(line) as LoggedRequestHeader) .find(candidate => candidate.type === 'request/header') const tools = event?.data?.header?.tools if (tools === undefined) throw new Error('session log has no request/header tools') - return Object.fromEntries(tools.map(tool => [tool.name, tool.parameters.required ?? []])) + return tools +} + +function assembledToolRequirements(log: PersistedLog): Record { + return Object.fromEntries(assembledTools(log).map(tool => [tool.name, tool.parameters.required ?? []])) +} + +function assembledToolDescriptions(log: PersistedLog): Record { + return Object.fromEntries(assembledTools(log).map((tool) => { + if (typeof tool.description !== 'string') throw new Error(`tool ${tool.name} has no description`) + return [tool.name, tool.description] + })) } function assembledSystem(log: PersistedLog): string { @@ -400,6 +433,16 @@ describe('TypeScript SDK snapshots over the jsonrpc runtime', () => { if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) expect(assembledToolRequirements(parent)).toEqual(scenario.expectedTools) } + if (scenario.expectedSystem !== undefined) { + const parent = ordered[0] + if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) + expect(assembledSystem(parent)).toBe(scenario.expectedSystem) + } + if (scenario.expectedToolDescriptions !== undefined) { + const parent = ordered[0] + if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) + expect(assembledToolDescriptions(parent)).toMatchObject(scenario.expectedToolDescriptions) + } if (scenario.policyContext !== undefined) { const parent = ordered[0] if (parent === undefined) throw new Error(`${scenario.name} has no parent session log`) diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl index 434027310a..fc0a24eb66 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/notifications.expected.jsonl @@ -106,37 +106,38 @@ {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}}}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"child"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" answer"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" "}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"42"}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":34,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":35,"time":0,"data":{"turn":1,"step":1}}}} -{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":36,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"user/message","seq":6,"time":0,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"{{sessionId}}"},"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"session/title","seq":7,"time":0,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/header","seq":8,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"request/context","seq":9,"time":0,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"The"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" user"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" wants"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":14,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" me"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":15,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" to"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" reply"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" with"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" exactly"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" \""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":24,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"reasoning-delta","index":0,"text":".\""}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":25,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":26,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"child"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":27,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" answer"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":" "}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"42"}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":1,"text":"."}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"assistant/message","seq":35,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"{{sessionId}}"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":1}}}} +{"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"turn/end","seq":37,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}}}} {"method":"session.status","params":{"sessionId":"{{sessionId}}","status":"idle"}} {"method":"subagent.finished","params":{"provider":"spawn","agentId":"{{sessionId}}","parentSessionId":"{{sessionId}}","childSessionId":"{{sessionId}}","status":"ok","stopReason":"completed","lastAssistantMessage":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}]}} {"method":"session.event","params":{"sessionId":"{{sessionId}}","event":{"type":"tool/result","seq":99,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"call_00_oHPNQ1nLoakoaAGXIxCM7404"},"content":[{"type":"tool-result","toolCallId":"call_00_oHPNQ1nLoakoaAGXIxCM7404","content":[{"type":"text","text":"child answer 42."}],"isError":false}],"role":"user","id":"{{sessionId}}"}},"sourceEventSeqs":[98],"surfaceOp":"append"}}} diff --git a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl index 9c45784001..0e7855cd86 100644 --- a/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl +++ b/examples/jsonrpc-agent/tests/snapshots/subagent-spawn/session.1.jsonl @@ -5,17 +5,18 @@ {"type":"subagent/descriptor","seq":3,"time":1785821461003,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"echo probe"}} {"type":"step/start","seq":4,"time":1785730507335,"data":{"turn":1,"step":1}} {"type":"user/message","seq":5,"time":1785730507335,"data":{"content":[{"type":"text","text":"Reply with exactly: child answer 42."}],"source":{"kind":"user"},"role":"user","id":"7ae1698c-db1d-4fca-8404-3a9dece9c1d0"},"surfaceOp":"append"} -{"type":"session/title","seq":6,"time":1785730507335,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}} -{"type":"request/header","seq":7,"time":1785498591175,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} -{"type":"request/context","seq":8,"time":1785730507336,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} -{"type":"assistant/chunk","seq":9,"time":1785097410985,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} -{"type":"reasoning-chunks","seq0":10,"time0":1785097411011,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,24,1,0,0,0,25,0,1,0,51,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","child"," answer"," ","42",".\""]}} -{"type":"assistant/chunk","seq":24,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} -{"type":"text-chunks","seq0":25,"time0":1785097411114,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,24,0],"texts":["child"," answer"," ","42","."]}} -{"type":"assistant/chunk","seq":30,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}} -{"type":"assistant/chunk","seq":31,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} -{"type":"assistant/chunk","seq":32,"time":1785498591184,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}} -{"type":"assistant/chunk","seq":33,"time":1785730507343,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":34,"time":1785730507344,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3d9970cd-d000-4fd5-8712-a88c301ddb19"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33],"surfaceOp":"append"} -{"type":"step/end","seq":35,"time":1785730507344,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":36,"time":1785730507344,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":6,"time":1786358111405,"data":{"content":[{"type":"text","text":"Current runtime context. This snapshot supersedes earlier runtime-context snapshots.\n\nYou are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}],"source":{"kind":"plugin","plugin":"@deepseek-ai/dsh-system-prompt","form":"snapshot","sections":[{"name":"subagent:delegation","text":"You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it."}]},"role":"user","id":"17bd0771-d228-4805-a797-7be9c0b59d20"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786358111405,"data":{"title":"Reply with exactly: child answer","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1785498591175,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1785730507336,"data":{"provider":"deepseek-official","model":"deepseek-v4-flash"}} +{"type":"assistant/chunk","seq":10,"time":1785097410985,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}} +{"type":"reasoning-chunks","seq0":11,"time0":1785097411011,"data":{"turn":1,"step":1,"index":0,"dt":[0,0,24,1,0,0,0,25,0,1,0,51,0],"texts":["The"," user"," wants"," me"," to"," reply"," with"," exactly"," \"","child"," answer"," ","42",".\""]}} +{"type":"assistant/chunk","seq":25,"time":1785097411114,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}} +{"type":"text-chunks","seq0":26,"time0":1785097411114,"data":{"turn":1,"step":1,"index":1,"dt":[0,0,24,0],"texts":["child"," answer"," ","42","."]}} +{"type":"assistant/chunk","seq":31,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""}}}} +{"type":"assistant/chunk","seq":32,"time":1785097411138,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"child answer 42."}}}} +{"type":"assistant/chunk","seq":33,"time":1785498591184,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}}}} +{"type":"assistant/chunk","seq":34,"time":1785730507343,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":35,"time":1785730507344,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"reasoning","text":"The user wants me to reply with exactly \"child answer 42.\""},{"type":"text","text":"child answer 42."}],"source":{"kind":"model","provider":"deepseek-official","model":"deepseek-v4-flash"},"id":"3d9970cd-d000-4fd5-8712-a88c301ddb19"},"usage":{"inputTokens":107,"outputTokens":20,"cacheReadTokens":1664,"reasoningTokens":14}},"sourceEventSeqs":[10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30,31,32,33,34],"surfaceOp":"append"} +{"type":"step/end","seq":36,"time":1785730507344,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":37,"time":1785730507344,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/examples/mcp-memory/README.i18n.yaml b/examples/mcp-memory/README.i18n.yaml index d1de74a676..9eaba5254b 100644 --- a/examples/mcp-memory/README.i18n.yaml +++ b/examples/mcp-memory/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write examples/mcp-memory/README.md -README.md: 58da672030eaf2ddf70ee92d506de300efcd9650 -README.zh.md: 0a2f109f9458ec7e1aba50e7fc9b6fd0fca15dbd +README.md: 7e7de76f4123481b78898b8d62228e4821f3ebc9 +README.zh.md: 3473af862011725844ede95dc9460dd91534b484 diff --git a/examples/mcp-memory/README.md b/examples/mcp-memory/README.md index 58da672030..7e7de76f41 100644 --- a/examples/mcp-memory/README.md +++ b/examples/mcp-memory/README.md @@ -30,18 +30,6 @@ dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" Replace the filename with `mcp-reference-memory.cordis.yml` or `engram.cordis.yml`. The path may point to a copied file anywhere on disk. No memory server is present in the shipped composition, so omitting `--patch` keeps all three disabled. -Without a repository checkout, download the selected overlay directly: - -```sh -mkdir -p "${DSH_HOME:-$HOME/.dsh}" -curl --fail --location \ - --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml -dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" -``` - -Replace `memorix.cordis.yml` in the URL with either of the other filenames to select it. Review a downloaded overlay before running it: Cordis configuration can contain executable `!!js` expressions. - To keep the selection across runs, merge the chosen file's single `insert` patch into a user patch layer — `$DSH_HOME/profiles//cordis.patch.yml` for one profile, or `$DSH_HOME/cordis.patch.yml` for every profile on the machine. Do not copy over an existing file: it may already contain unrelated user patches. ## Provider setup diff --git a/examples/mcp-memory/README.zh.md b/examples/mcp-memory/README.zh.md index 0a2f109f94..3473af8620 100644 --- a/examples/mcp-memory/README.zh.md +++ b/examples/mcp-memory/README.zh.md @@ -30,18 +30,6 @@ dsh web --patch "$PWD/examples/mcp-memory/memorix.cordis.yml" 请将文件名替换为 `mcp-reference-memory.cordis.yml` 或 `engram.cordis.yml`。该路径可以指向磁盘任意位置的一份复制文件。交付组合不包含任何记忆服务器,因此不传 `--patch` 就会让这三项全部保持关闭。 -如果本地没有仓库 checkout,可直接下载所选 overlay: - -```sh -mkdir -p "${DSH_HOME:-$HOME/.dsh}" -curl --fail --location \ - --output "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" \ - https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/examples/mcp-memory/memorix.cordis.yml -dsh web --patch "${DSH_HOME:-$HOME/.dsh}/memory.cordis.yml" -``` - -若要选择另外任一配置,请将 URL 中的 `memorix.cordis.yml` 替换为对应文件名。运行下载的 overlay 前,请先审阅其内容:Cordis 配置可以包含可执行的 `!!js` 表达式。 - 如果要跨次运行保留所选配置,请将对应文件中的单个 `insert` patch 合并到用户 patch 层:只对一个 profile 生效则写入 `$DSH_HOME/profiles//cordis.patch.yml`,对本机所有 profile 生效则写入 `$DSH_HOME/cordis.patch.yml`。不要覆盖已有文件,其中可能已经包含无关的用户 patch。 ## 提供方设置 diff --git a/examples/package.json b/examples/package.json index 48ac39f5ca..29818133ef 100644 --- a/examples/package.json +++ b/examples/package.json @@ -5,15 +5,16 @@ "type": "module", "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", "dependencies": { - "@cordisjs/plugin-hmr": "workspace:*", - "@cordisjs/plugin-include": "workspace:*", - "@cordisjs/plugin-logger-console": "workspace:*", - "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/cordis-plugin-hmr": "workspace:*", + "@deepseek-ai/cordis-plugin-include": "workspace:*", + "@deepseek-ai/cordis-plugin-logger-console": "workspace:*", + "@deepseek-ai/cordis-plugin-timer": "workspace:*", "@deepseek-ai/dsh-acp-demo": "workspace:*", "@deepseek-ai/dsh-agent": "workspace:*", "@deepseek-ai/dsh-agent-loop": "workspace:*", "@deepseek-ai/dsh-agent-spine-demo": "workspace:*", "@deepseek-ai/dsh-app-boot": "workspace:*", + "@deepseek-ai/dsh-attachment-local": "workspace:*", "@deepseek-ai/dsh-bash": "workspace:*", "@deepseek-ai/dsh-bash-env": "workspace:*", "@deepseek-ai/dsh-bash-local": "workspace:*", @@ -27,8 +28,8 @@ "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:*", "@deepseek-ai/dsh-credentials-local": "workspace:*", "@deepseek-ai/dsh-e2b": "workspace:*", - "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-e2b": "workspace:*", + "@deepseek-ai/dsh-fs-local": "workspace:*", "@deepseek-ai/dsh-fs-policy": "workspace:*", "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:*", @@ -50,7 +51,6 @@ "@deepseek-ai/dsh-pty-local": "workspace:*", "@deepseek-ai/dsh-pwsh-local": "workspace:*", "@deepseek-ai/dsh-repeat-tool-guard": "workspace:*", - "@deepseek-ai/dsh-repository-plugin": "workspace:*", "@deepseek-ai/dsh-sandbox": "workspace:*", "@deepseek-ai/dsh-sandbox-local": "workspace:*", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", @@ -77,8 +77,8 @@ "@deepseek-ai/dsh-subagent-dsh-sdk": "workspace:*", "@deepseek-ai/dsh-subagent-fork": "workspace:*", "@deepseek-ai/dsh-subagent-spawn": "workspace:*", - "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-subprocess-e2b": "workspace:*", + "@deepseek-ai/dsh-subprocess-local": "workspace:*", "@deepseek-ai/dsh-system-prompt": "workspace:*", "@deepseek-ai/dsh-tasks-local": "workspace:*", "@deepseek-ai/dsh-time-context": "workspace:*", diff --git a/knip.json b/knip.json index 249d21d857..2f755dbb5f 100644 --- a/knip.json +++ b/knip.json @@ -47,6 +47,7 @@ "headless-agent/tests/fixtures/e2b/e2b/bin.ts", "acp-agent/tests/snapshots/lsp-definition/workspace/subject.ts", "acp-agent/tests/fixtures/child-question-tripwire.ts", + "acp-agent/tests/fixtures/parent-sandbox-override.ts", "acp-agent/tests/fixtures/partial-landlock-sandbox.ts", "acp-agent/tests/fixtures/subagent-durability-failure.ts", "acp-agent/tests/fixtures/subagent-settlement-marker.ts", @@ -67,7 +68,6 @@ "**/*.ts" ], "ignoreDependencies": [ - "@cordisjs/plugin-logger-console", "@deepseek-ai/.+" ] }, @@ -717,20 +717,6 @@ "@deepseek-ai/.+" ] }, - "apps/cli/tests/fixtures/github-repository-plugin/.dsh-plugin": { - "entry": [ - "src/*.ts" - ], - "project": [ - "src/**/*.ts" - ], - "ignoreDependencies": [ - "@deepseek-ai/dsh-repository-plugin" - ], - "ignoreBinaries": [ - "dsh-plugin-prepare" - ] - }, "packages/client/modules": { "entry": [ "tests/**/*.spec.ts" @@ -761,8 +747,7 @@ }, "packages/bundle/base": { "ignoreDependencies": [ - "@deepseek-ai/.+", - "@cordisjs/.+" + "@deepseek-ai/.+" ] }, "packages/bundle/headless": { diff --git a/lefthook.yml b/lefthook.yml index 0ea7f4e537..1c2a693c88 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -6,8 +6,14 @@ pre-commit: jobs: - name: translation pairing (staged records) glob: '*.i18n.yaml' + exclude: + - '.agents/notes/archived/**' run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files} + - name: archived agent notes + glob: '.agents/notes/archived/**' + run: node_modules/.bin/tsx scripts/verify-archived-agent-notes.ts + - name: lint (staged) glob: '*.{ts,tsx,mts,cts,mjs}' exclude: @@ -35,8 +41,14 @@ pre-merge-commit: jobs: - name: translation pairing (staged records) glob: '*.i18n.yaml' + exclude: + - '.agents/notes/archived/**' run: node_modules/.bin/tsx scripts/verify-translation-pairing.ts --cached {staged_files} + - name: archived agent notes + glob: '.agents/notes/archived/**' + run: node_modules/.bin/tsx scripts/verify-archived-agent-notes.ts + pre-push: jobs: - name: typecheck diff --git a/native/landlock-run/packages/entry/package.json b/native/landlock-run/packages/entry/package.json index 1614df5ad2..b3384a58c0 100644 --- a/native/landlock-run/packages/entry/package.json +++ b/native/landlock-run/packages/entry/package.json @@ -32,7 +32,7 @@ }, "license": "BSD-3-Clause", "publishConfig": { - "access": "public" + "access": "restricted" }, "optionalDependencies": { "@deepseek-ai/node-addon-landlock-run-linux-arm64": "workspace:*", diff --git a/native/landlock-run/packages/linux-arm64/package.json b/native/landlock-run/packages/linux-arm64/package.json index 14190e4765..11d9384c87 100644 --- a/native/landlock-run/packages/linux-arm64/package.json +++ b/native/landlock-run/packages/linux-arm64/package.json @@ -26,6 +26,6 @@ }, "license": "BSD-3-Clause", "publishConfig": { - "access": "public" + "access": "restricted" } } diff --git a/native/landlock-run/packages/linux-x64/package.json b/native/landlock-run/packages/linux-x64/package.json index 43c092d17b..6e3ad395e6 100644 --- a/native/landlock-run/packages/linux-x64/package.json +++ b/native/landlock-run/packages/linux-x64/package.json @@ -26,6 +26,6 @@ }, "license": "BSD-3-Clause", "publishConfig": { - "access": "public" + "access": "restricted" } } diff --git a/package.json b/package.json index 2830640c26..e1e64e5c46 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@deepseek-ai/dsh-root", - "version": "0.0.1", + "version": "0.0.1-rc.1", "private": true, "type": "module", "packageManager": "pnpm@11.7.0", @@ -54,10 +54,10 @@ "check:ci:snapshot": "tsx scripts/run-gates.ts ci-snapshot", "check:ci:artifacts": "tsx scripts/run-gates.ts ci-artifacts", "check:ci:consumers": "tsx scripts/run-gates.ts ci-consumers", + "check:windows-wine": "bash scripts/wine-windows-gates.sh", "check:ci:windows-blocking": "tsx scripts/run-gates.ts ci-windows-blocking", "check:ci:windows-complete": "tsx scripts/run-gates.ts ci-windows-complete", "check:ci:windows-observational": "tsx scripts/run-gates.ts ci-windows-observational", - "check:windows-wine": "bash scripts/wine-windows-gates.sh", "check:node-compat": "tsx scripts/run-gates.ts node-compat", "knip": "knip --treat-config-hints-as-errors", "publint": "tsx scripts/publint-all.ts", @@ -95,6 +95,8 @@ "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", + "rescope-vendor": "tsx scripts/rescope-vendor.ts", + "rescope-vendor:check": "tsx scripts/rescope-vendor.ts --check", "verify-client-domain-graph": "tsx scripts/verify-client-domain-graph.ts", "gen-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts", "verify-cordis-catalog": "tsx scripts/gen-cordis-catalog.ts --check", @@ -117,14 +119,18 @@ "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 && pnpm run verify-vendored-links", + "hygiene": "pnpm run rescope-vendor:check && 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", "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", - "dsh": "node --import tsx/esm apps/cli/src/bin.ts", - "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts run", + "release:dsh": "tsx scripts/release/bump.ts --family dsh", + "release:vendor": "tsx scripts/release/bump.ts --family vendor", + "release:verify": "tsx scripts/release/verify.ts", + "release:pack": "tsx scripts/release/pack.ts", + "release:verify-packed-install": "tsx scripts/release/verify-packed-install.ts", + "release:publish": "tsx scripts/release/publish.ts", + "dsh": "pnpm run build && node --import tsx/esm apps/cli/src/bin.ts", "demo:code-mode": "node scripts/demo-code-mode.mjs", "demo:cordis": "node scripts/demo-cordis.mjs", "demo:acp": "node --import tsx packages/examples/acp-demo/src/bin.ts --config examples/acp-agent/cordis.yml", - "demo:web": "npm run build && node --import tsx/esm apps/cli/src/bin.ts web", "mock:llm": "node --import tsx packages/support/llm-mock-server/src/bin.ts", "dev:web": "tsx scripts/dev-web.ts --poll", "postinstall": "node scripts/install-lefthook.mjs" diff --git a/packages/README.i18n.yaml b/packages/README.i18n.yaml index d77d9ed870..5f2d8b0585 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: c246534a26cd7297f2ba8885099c8b517a5dc2b0 -README.zh.md: 4139874d3ebbf82fad8680a3721a1cf35a553706 +README.md: eb7df95bde10dafd7afcb168d30c9dda90296687 +README.zh.md: 03cd02510267d3abdd414bed6ec1f42773d0811a diff --git a/packages/README.md b/packages/README.md index c246534a26..eb7df95bde 100644 --- a/packages/README.md +++ b/packages/README.md @@ -38,7 +38,7 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`preset/`](preset/README.md) | Per-session agent composition from preset `cordis.yml` files | Product — stable surface | | [`guard/`](guard/README.md) | Loop-hygiene guards: advisory repeat-call reminders + the `tools/execute` deadline enforcer | Product — stable surface | | [`bundle/`](bundle/README.md) | Installable `dsh --profile` patch layers | Product — stable surface | -| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection, model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)), restricted repository Plugin loading | Product — stable surface | +| [`self-modification/`](self-modification/README.md) | Agent runtime self-modification: live plugin/service inspection and model-written plugin mount/unmount ([design](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | Product — stable surface | | [`hooks/`](hooks/README.md) | Hook bridges + the shared Claude Code / Codex wire-protocol library | Product — stable surface | | [`session/`](session/README.md) | Durable session data plane: persistence seam + JSONL/SQLite backends, projection seam, log-backed titles, session reporting | Product — stable surface | | [`session-query/`](session-query/README.md) | Session retrieval family: logical corpus, bounded reads, lineage, event relationships, semantic filtering, and SQLite full-text search | Product — stable surface | @@ -52,7 +52,6 @@ Groups hold `packages///`; names stay `@deepseek-ai/dsh-`. **Gr | [`boot/`](boot/README.md) | Shared app-bin boot glue | Product — stable surface | | [`host/`](host/README.md) | Web-GUI host half: API gateway + HTTP route server | Product — stable surface | | [`client/`](client/README.md) | Web-GUI browser half: shell, wire, object services, slots, `ui-*` plugins | Product — stable surface | -| [`experimental/`](experimental/README.md) | Prototypes and internal plugins | Unreleased | | [`examples/`](examples/README.md) | Demo bundles (agent-spine + CLI/ACP/JSON-RPC bins) leaves load | Support — example infra | | [`support/`](support/README.md) | Support infrastructure (testkits, invariants, replay, Loader smokes) | Support — lower compatibility expectations | | [`util/`](util/README.md) | Low-level zero-dependency utilities shared across groups (`Branded`, Harness home/path helpers, timeout, retention) | Support — small, stable, harness-dep-free | diff --git a/packages/README.zh.md b/packages/README.zh.md index 4139874d3e..03cd025102 100644 --- a/packages/README.zh.md +++ b/packages/README.zh.md @@ -38,7 +38,7 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`preset/`](preset/README.md) | 由 preset `cordis.yml` 按会话组装 agent | 产品:稳定接口 | | [`guard/`](guard/README.md) | 循环卫生守卫:建议性重复调用提醒 + `tools/execute` 截止时间强制执行器 | 产品:稳定接口 | | [`bundle/`](bundle/README.md) | 可安装的 `dsh --profile` 补丁层 | 产品:稳定接口 | -| [`self-modification/`](self-modification/README.md) | agent 运行时自修改:实时插件/服务检查、模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md))、受限仓库插件加载 | 产品:稳定接口 | +| [`self-modification/`](self-modification/README.md) | agent 运行时自修改:实时插件/服务检查和模型所写插件挂载/卸载([设计](../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)) | 产品:稳定接口 | | [`hooks/`](hooks/README.md) | 钩子桥接 + 共享 Claude Code/Codex 协议格式库 | 产品:稳定接口 | | [`session/`](session/README.md) | 持久会话数据平面:持久化 seam + JSONL/SQLite 后端、投影 seam、日志支持的标题、会话上报 | 产品:稳定接口 | | [`session-query/`](session-query/README.md) | 会话检索系列:逻辑语料库、有界读取、血缘、事件关系、语义过滤和 SQLite 全文搜索 | 产品:稳定接口 | @@ -52,7 +52,6 @@ npm scope 为 `@deepseek-ai/dsh-*`;Cordis `Service` 子类和函数插件通 | [`boot/`](boot/README.md) | 共享的 app bin 启动粘合层 | 产品:稳定接口 | | [`host/`](host/README.md) | web GUI 宿主半侧:API 网关 + HTTP 路由服务器 | 产品:稳定接口 | | [`client/`](client/README.md) | web GUI 浏览器半侧:shell、协议层、对象服务、slot、`ui-*` 插件 | 产品:稳定接口 | -| [`experimental/`](experimental/README.md) | 原型和内部插件 | 未发布 | | [`examples/`](examples/README.md) | 演示组合包(agent-spine + CLI/ACP/JSON-RPC bin),由叶节点加载 | 支持:示例基础设施 | | [`support/`](support/README.md) | 支持基础设施(testkit、不变式、回放、Loader 冒烟测试) | 支持:兼容性预期较低 | | [`util/`](util/README.md) | 组间共享的低层零依赖工具(`Branded`、Harness home/路径辅助函数、超时、保留策略) | 支持:小型、稳定、无 harness 依赖 | diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 7aa9f8c4ef..b244c0367d 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-acp", "description": "Automation-only Agent Client Protocol server for driving DeepSeek Harness agents over JSON-RPC stdio", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/acp/acp" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -26,14 +33,14 @@ "license": "BSD-3-Clause", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", - "schemastery": "^3.17.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -44,6 +51,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/acp/acp/src/index.ts b/packages/acp/acp/src/index.ts index ce00fb105a..d595c69e69 100644 --- a/packages/acp/acp/src/index.ts +++ b/packages/acp/acp/src/index.ts @@ -9,11 +9,11 @@ * @module @deepseek-ai/dsh-acp */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { randomUUID } from 'node:crypto' import { isAbsolute } from 'node:path' import { Readable, Writable } from 'node:stream' -import Schema from 'schemastery' +import Schema from '@deepseek-ai/schemastery' import { createUserMessage, errorChain } from '@deepseek-ai/dsh-llm' import { AgentSideConnection, @@ -252,6 +252,10 @@ export function apply(ctx: Context, config: AcpConfig): void { assertOpen() validateSessionParams(params) const sessionId = SessionId(randomUUID()) + // No preset composition: the ACP bundle keeps the model-facing rows in + // the host plane, so this agent reads them from the global layer. A + // deployment that configures a roster has to join one here first + // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). const handle = await agents.create({ sessionId, meta: { cwd: params.cwd }, diff --git a/packages/acp/acp/src/invariant.ts b/packages/acp/acp/src/invariant.ts index 9d5b769872..d4db1c964c 100644 --- a/packages/acp/acp/src/invariant.ts +++ b/packages/acp/acp/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp' diff --git a/packages/acp/acp/tests/harness.ts b/packages/acp/acp/tests/harness.ts index aa3564ea8a..c5b03c39a2 100644 --- a/packages/acp/acp/tests/harness.ts +++ b/packages/acp/acp/tests/harness.ts @@ -1,6 +1,6 @@ /** In-memory ACP transport fixture over the real agent factory and loop. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { ClientSideConnection, ndJsonStream, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 0b76b607cd..48615dc55f 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "TypeRT Remote Host dispatcher and Client API endpoint", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/gateway" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -52,17 +59,17 @@ "@deepseek-ai/dsh-type-meta": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-typert-registry": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "zod": "^4.4.3" } } diff --git a/packages/api/gateway/src/client/index.ts b/packages/api/gateway/src/client/index.ts index e49e9e5822..847998f173 100644 --- a/packages/api/gateway/src/client/index.ts +++ b/packages/api/gateway/src/client/index.ts @@ -4,8 +4,8 @@ * participates in method lookup, invocation, or type exposure. */ -import { Service } from 'cordis' -import type { Context } from 'cordis' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, RpcError } from '@deepseek-ai/dsh-client-connection/client' import type { InvocationDescriptor, @@ -53,7 +53,7 @@ interface RemoteNamespaceHandle { /** Typed Remote service augmented by generated direct namespaces. */ export type ClientRemote = TypeRTClientRemote -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Generated Remote namespaces selected by the Client assembly. */ remote: ClientRemote diff --git a/packages/api/gateway/src/index.ts b/packages/api/gateway/src/index.ts index 5899f5d560..ee4b063622 100644 --- a/packages/api/gateway/src/index.ts +++ b/packages/api/gateway/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-api-gateway */ -import { Context, Service, symbols } from 'cordis' +import { Context, Service, symbols } from '@deepseek-ai/cordis' import type { ConnectionRpcHandler } from '@deepseek-ai/dsh-client-connection' import { remoteMethods, diff --git a/packages/api/gateway/src/invariant.ts b/packages/api/gateway/src/invariant.ts index 711c4edab5..a09d2fa776 100644 --- a/packages/api/gateway/src/invariant.ts +++ b/packages/api/gateway/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-api-gateway' diff --git a/packages/api/gateway/src/types.ts b/packages/api/gateway/src/types.ts index 0917ba2ca6..7a63c8c45a 100644 --- a/packages/api/gateway/src/types.ts +++ b/packages/api/gateway/src/types.ts @@ -46,7 +46,7 @@ export interface TypertGateway { invoke(request: InvokeRemoteRequest): Promise } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Host dispatcher for TypeRT Remote calls. */ typertGateway: TypertGateway diff --git a/packages/api/gateway/tests/client.spec.ts b/packages/api/gateway/tests/client.spec.ts index 641ea81ebc..b253547c7d 100644 --- a/packages/api/gateway/tests/client.spec.ts +++ b/packages/api/gateway/tests/client.spec.ts @@ -1,4 +1,4 @@ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { z } from 'zod' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/api/gateway/tests/gateway.spec.ts b/packages/api/gateway/tests/gateway.spec.ts index 4fa0ea80ad..38be5c822d 100644 --- a/packages/api/gateway/tests/gateway.spec.ts +++ b/packages/api/gateway/tests/gateway.spec.ts @@ -1,7 +1,7 @@ import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import { describe, expect, it } from 'vitest' -import { Context, Service, symbols } from 'cordis' +import { Context, Service, symbols } from '@deepseek-ai/cordis' import { z } from 'zod' import { apply as applyConnection, inject as connectionInject } from '@deepseek-ai/dsh-client-connection' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 3112a1e7c6..241a86ff9f 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly and Host Agent/Session lookup policy", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/api/remotes" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -46,13 +53,13 @@ "@deepseek-ai/dsh-type-meta": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-goal": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-typert-registry": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -61,6 +68,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/api/remotes/src/agent-lookup.ts b/packages/api/remotes/src/agent-lookup.ts index db765f3dde..c7b4018546 100644 --- a/packages/api/remotes/src/agent-lookup.ts +++ b/packages/api/remotes/src/agent-lookup.ts @@ -1,6 +1,6 @@ /** Host BFF policy for resolving Remote Agent and Session identities. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent, AgentOptions, AgentSetup } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-persistence' diff --git a/packages/api/remotes/src/client/index.ts b/packages/api/remotes/src/client/index.ts index be92b02d77..ce67fb05d7 100644 --- a/packages/api/remotes/src/client/index.ts +++ b/packages/api/remotes/src/client/index.ts @@ -1,13 +1,13 @@ /** Platform-neutral assembly of generated Host Remote contributions. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import goalsRemote from '@deepseek-ai/dsh-goal/remote' import type { TypeRTClientRemote } from '@deepseek-ai/dsh-type-meta' export type { TypeRTClientRemote as ClientRemote } from '@deepseek-ai/dsh-type-meta' export type {} from '@deepseek-ai/dsh-goal/remote' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Generated Remote namespaces selected by this Client assembly. */ remote: TypeRTClientRemote diff --git a/packages/api/remotes/src/invariant.ts b/packages/api/remotes/src/invariant.ts index 3310bed11f..f93b63e98b 100644 --- a/packages/api/remotes/src/invariant.ts +++ b/packages/api/remotes/src/invariant.ts @@ -1,7 +1,7 @@ /** Package-owned invariant companion for `@deepseek-ai/dsh-api-remotes`. */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-api-remotes' diff --git a/packages/api/remotes/tests/agent-lookup.spec.ts b/packages/api/remotes/tests/agent-lookup.spec.ts index 7179f5b2b2..ed403f7f4d 100644 --- a/packages/api/remotes/tests/agent-lookup.spec.ts +++ b/packages/api/remotes/tests/agent-lookup.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/api/remotes/tests/built-lib.e2e.ts b/packages/api/remotes/tests/built-lib.e2e.ts index af584cba7f..be76cd148c 100644 --- a/packages/api/remotes/tests/built-lib.e2e.ts +++ b/packages/api/remotes/tests/built-lib.e2e.ts @@ -45,7 +45,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { }).map(([key, path]) => [key, artifactUrl(path)])) const script = ` import { createServer } from 'node:http' - import * as cordis from 'cordis' + import * as cordis from '@deepseek-ai/cordis' const urls = ${JSON.stringify(urls)} const { Context } = cordis @@ -123,7 +123,7 @@ describe.skipIf(!requiredArtifacts)('Goal Remote built LIB chain', () => { const handoff = handoffs.get(id) if (handoff === undefined) throw new Error('missing Client bundle handoff ' + id) return handoff.factory(specifier => { - if (specifier === 'cordis') return cordis + if (specifier === '@deepseek-ai/cordis') return cordis throw new Error('unexpected Client external ' + specifier) }) } diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 1834ef2d90..3bbf361032 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/attachment/attachment-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -15,19 +22,19 @@ "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-attachment": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "sharp": "^0.35.3" }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/attachment/attachment-local/src/index.ts b/packages/attachment/attachment-local/src/index.ts index 7ed4824ef2..3d67041ea4 100644 --- a/packages/attachment/attachment-local/src/index.ts +++ b/packages/attachment/attachment-local/src/index.ts @@ -1,8 +1,8 @@ /** Local durable attachment backend rooted below `DSH_HOME`. @module @deepseek-ai/dsh-attachment-local */ import { join, resolve } from 'node:path' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import { resolveDshHome } from '@deepseek-ai/dsh-paths' diff --git a/packages/attachment/attachment-local/src/invariant.ts b/packages/attachment/attachment-local/src/invariant.ts index eb14a84af6..2e37667801 100644 --- a/packages/attachment/attachment-local/src/invariant.ts +++ b/packages/attachment/attachment-local/src/invariant.ts @@ -1,7 +1,7 @@ /** Package-owned invariant companion for `@deepseek-ai/dsh-attachment-local`. @module @deepseek-ai/dsh-attachment-local/invariant */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-attachment-local' diff --git a/packages/attachment/attachment-local/tests/index.spec.ts b/packages/attachment/attachment-local/tests/index.spec.ts index 8aad68d2ff..e196966fa4 100644 --- a/packages/attachment/attachment-local/tests/index.spec.ts +++ b/packages/attachment/attachment-local/tests/index.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { existsSync } from 'node:fs' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 66cecc1894..1fa800d2bf 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/attachment/attachment" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -15,13 +22,13 @@ "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/attachment/attachment/src/index.ts b/packages/attachment/attachment/src/index.ts index 9b3b8dd92b..d2dc2dbd86 100644 --- a/packages/attachment/attachment/src/index.ts +++ b/packages/attachment/attachment/src/index.ts @@ -1,6 +1,6 @@ /** Durable attachment storage seam (`ctx.attachments`). @module @deepseek-ai/dsh-attachment */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { ImageAttachmentLimits, ImageAttachmentRef, @@ -19,7 +19,7 @@ export type { StoredImageAttachment, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { attachments: AttachmentStore } diff --git a/packages/attachment/attachment/src/invariant.ts b/packages/attachment/attachment/src/invariant.ts index 2c00d56ece..a44607b093 100644 --- a/packages/attachment/attachment/src/invariant.ts +++ b/packages/attachment/attachment/src/invariant.ts @@ -1,7 +1,7 @@ /** Package-owned invariant companion for `@deepseek-ai/dsh-attachment`. @module @deepseek-ai/dsh-attachment/invariant */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-attachment' diff --git a/packages/bash/bash-env/README.i18n.yaml b/packages/bash/bash-env/README.i18n.yaml index e09b4b1405..2845459293 100644 --- a/packages/bash/bash-env/README.i18n.yaml +++ b/packages/bash/bash-env/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/bash/bash-env/README.md -README.md: 7b939326d4effd14fc83ef0ad4e133f019f1011f -README.zh.md: 4d80d9d34f2be18e07d57d2427eb841f61f1ccfc +README.md: 54758a91773aad723c6fbbebe2ffe25aedb38599 +README.zh.md: e0663b003e94942c76cde4fb7867ed0ce74c2aa2 diff --git a/packages/bash/bash-env/README.md b/packages/bash/bash-env/README.md index 7b939326d4..54758a9177 100644 --- a/packages/bash/bash-env/README.md +++ b/packages/bash/bash-env/README.md @@ -22,7 +22,7 @@ Every foreground and background model shell call receives a newly collected trus `ctx.bashEnv` owns collection. Other plugins can register an effect-scoped contributor with a stable name, declared keys/descriptions, and `resolve(execution: ToolExecution)`; duplicate ownership and undeclared runtime keys fail loudly, while `list()` enumerates declarations without executing providers. Harness built-ins reserve `DSH_HOME`, `DSH_SHELL`, and `DSH_SESSION_ID`; this plugin's persistence translator owns `DSH_SESSION_JSONL` by reading the backend-neutral `sessionPersistence.locate()` seam. ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-bash-env' export const inject = ['bashEnv'] diff --git a/packages/bash/bash-env/README.zh.md b/packages/bash/bash-env/README.zh.md index 4d80d9d34f..e0663b003e 100644 --- a/packages/bash/bash-env/README.zh.md +++ b/packages/bash/bash-env/README.zh.md @@ -22,7 +22,7 @@ `ctx.bashEnv` 负责收集。其他插件可以注册一个受 effect 作用域约束的 contributor,带有稳定名称、已声明的键/描述以及 `resolve(execution: ToolExecution)`;重复所有权与未声明的运行时键会响亮失败,而 `list()` 只枚举声明、不执行 provider。Harness 内置键保留 `DSH_HOME`、`DSH_SHELL` 与 `DSH_SESSION_ID`;本插件的持久化翻译器通过读取与后端无关的 `sessionPersistence.locate()` seam 拥有 `DSH_SESSION_JSONL`。 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-bash-env' export const inject = ['bashEnv'] diff --git a/packages/bash/bash-env/package.json b/packages/bash/bash-env/package.json index 33243ab344..acde7f422f 100644 --- a/packages/bash/bash-env/package.json +++ b/packages/bash/bash-env/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-bash-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bash/bash-env" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -43,6 +50,6 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/bash-env/src/index.ts b/packages/bash/bash-env/src/index.ts index c7caa89f08..6bdbbc2623 100644 --- a/packages/bash/bash-env/src/index.ts +++ b/packages/bash/bash-env/src/index.ts @@ -8,15 +8,15 @@ * @module @deepseek-ai/dsh-bash-env */ -import { Service, type Context } from 'cordis' -import z from 'schemastery' +import { Service, type Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { DSH_ENV_PREFIX } from '@deepseek-ai/dsh-bash' import type { DshEnvironment, DshEnvironmentKey } from '@deepseek-ai/dsh-bash' import { DSH_HOME_ENV, resolveDshHome } from '@deepseek-ai/dsh-paths' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-session-persistence' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { bashEnv: BashEnvRegistry } diff --git a/packages/bash/bash-env/src/invariant.ts b/packages/bash/bash-env/src/invariant.ts index 31f842c56d..fff86f91c1 100644 --- a/packages/bash/bash-env/src/invariant.ts +++ b/packages/bash/bash-env/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-env' diff --git a/packages/bash/bash-env/tests/bash-env.spec.ts b/packages/bash/bash-env/tests/bash-env.spec.ts index eb482fd9d3..ab7905fa13 100644 --- a/packages/bash/bash-env/tests/bash-env.spec.ts +++ b/packages/bash/bash-env/tests/bash-env.spec.ts @@ -7,7 +7,7 @@ import { homedir } from 'node:os' import { join, resolve } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ToolExecution } from '@deepseek-ai/dsh-tools' diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index c3c3a5c3e6..a46c5b6502 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bash/bash-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,14 +32,14 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", @@ -40,6 +47,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/bash-local/src/index.ts b/packages/bash/bash-local/src/index.ts index 3d3ca833bc..2c8e200fc3 100644 --- a/packages/bash/bash-local/src/index.ts +++ b/packages/bash/bash-local/src/index.ts @@ -9,8 +9,8 @@ * @module @deepseek-ai/dsh-bash-local */ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' diff --git a/packages/bash/bash-local/src/invariant.ts b/packages/bash/bash-local/src/invariant.ts index 3cc7bd62e2..55905cbf5e 100644 --- a/packages/bash/bash-local/src/invariant.ts +++ b/packages/bash/bash-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-local' diff --git a/packages/bash/bash-local/tests/executor.spec.ts b/packages/bash/bash-local/tests/executor.spec.ts index af5566086f..607eb48dd1 100644 --- a/packages/bash/bash-local/tests/executor.spec.ts +++ b/packages/bash/bash-local/tests/executor.spec.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LocalBashExecutor } from '@deepseek-ai/dsh-bash-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index a771acb1d3..293748a67e 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-bash-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness bash executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bash/bash-sandbox" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,12 +32,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-bash-local": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", @@ -40,7 +47,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/node-addon-landlock-run": "workspace:*" } } diff --git a/packages/bash/bash-sandbox/src/index.ts b/packages/bash/bash-sandbox/src/index.ts index dc299f49c3..95e8807e1e 100644 --- a/packages/bash/bash-sandbox/src/index.ts +++ b/packages/bash/bash-sandbox/src/index.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-bash-sandbox */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { diff --git a/packages/bash/bash-sandbox/src/invariant.ts b/packages/bash/bash-sandbox/src/invariant.ts index b79b626033..4592d633af 100644 --- a/packages/bash/bash-sandbox/src/invariant.ts +++ b/packages/bash/bash-sandbox/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash-sandbox' diff --git a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts index 437078440c..2e6567b8d8 100644 --- a/packages/bash/bash-sandbox/tests/bwrap.e2e.ts +++ b/packages/bash/bash-sandbox/tests/bwrap.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { bwrapProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' diff --git a/packages/bash/bash-sandbox/tests/landlock.e2e.ts b/packages/bash/bash-sandbox/tests/landlock.e2e.ts index 7255ee43c9..e9c25b13e5 100644 --- a/packages/bash/bash-sandbox/tests/landlock.e2e.ts +++ b/packages/bash/bash-sandbox/tests/landlock.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' diff --git a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts index 9176ac320b..7bb63eeb57 100644 --- a/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts +++ b/packages/bash/bash-sandbox/tests/partial-landlock.spec.ts @@ -8,7 +8,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' diff --git a/packages/bash/bash-sandbox/tests/sandbox.spec.ts b/packages/bash/bash-sandbox/tests/sandbox.spec.ts index 9c3eceda1e..a0dbec3645 100644 --- a/packages/bash/bash-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/bash-sandbox/tests/sandbox.spec.ts @@ -9,7 +9,7 @@ import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import { SANDBOX_UNAVAILABLE, SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, SandboxExecutionPolicy, SandboxMode, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' diff --git a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts index c03e407986..e3d91fb33a 100644 --- a/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts +++ b/packages/bash/bash-sandbox/tests/seatbelt.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { homedir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { SandboxPolicyService } from '@deepseek-ai/dsh-sandbox-policy' import { seatbeltProfileArgs } from '@deepseek-ai/dsh-sandbox-local/src/profiles.ts' diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 71e9ed8d9f..0821a526c8 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-bash", "description": "Abstract bash executor seam (ctx.bash) for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bash/bash" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/bash/src/index.ts b/packages/bash/bash/src/index.ts index 73f3d7b519..30d5840052 100644 --- a/packages/bash/bash/src/index.ts +++ b/packages/bash/bash/src/index.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-bash */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from './types.ts' @@ -25,7 +25,7 @@ export type { export { parseExitStatus } from './render.ts' export type { ParsedExitStatus } from './render.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { bash: BashExecutor } diff --git a/packages/bash/bash/src/invariant.ts b/packages/bash/bash/src/invariant.ts index acf00f48a4..35f0382061 100644 --- a/packages/bash/bash/src/invariant.ts +++ b/packages/bash/bash/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned invariant companion for the bash seam. @module @deepseek-ai/dsh-bash/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-bash' diff --git a/packages/bash/bash/tests/service.spec.ts b/packages/bash/bash/tests/service.spec.ts index cacfe85eca..2306c0293d 100644 --- a/packages/bash/bash/tests/service.spec.ts +++ b/packages/bash/bash/tests/service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' diff --git a/packages/bash/pwsh-local/package.json b/packages/bash/pwsh-local/package.json index f65d524904..59e8714608 100644 --- a/packages/bash/pwsh-local/package.json +++ b/packages/bash/pwsh-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bash/pwsh-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,14 +32,14 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", @@ -40,6 +47,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/pwsh-local/src/index.ts b/packages/bash/pwsh-local/src/index.ts index 5983500772..16f5c4479f 100644 --- a/packages/bash/pwsh-local/src/index.ts +++ b/packages/bash/pwsh-local/src/index.ts @@ -13,8 +13,8 @@ * @module @deepseek-ai/dsh-pwsh-local */ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult, CollectedOutput } from '@deepseek-ai/dsh-bash' import type { SubprocessCollect, SubprocessHandle, SubprocessOutputReader, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' diff --git a/packages/bash/pwsh-local/src/invariant.ts b/packages/bash/pwsh-local/src/invariant.ts index 4bb1c1ea30..9b6db9426b 100644 --- a/packages/bash/pwsh-local/src/invariant.ts +++ b/packages/bash/pwsh-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-local' diff --git a/packages/bash/pwsh-local/tests/executor.spec.ts b/packages/bash/pwsh-local/tests/executor.spec.ts index ef5e972fc0..6f38af8153 100644 --- a/packages/bash/pwsh-local/tests/executor.spec.ts +++ b/packages/bash/pwsh-local/tests/executor.spec.ts @@ -14,7 +14,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { PwshLocalExecutor, ENCODING_PREAMBLE, candidatePwshPaths, resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import SubprocessService from '@deepseek-ai/dsh-subprocess' diff --git a/packages/bash/pwsh-sandbox/README.i18n.yaml b/packages/bash/pwsh-sandbox/README.i18n.yaml index f8100bf8a0..4bde957978 100644 --- a/packages/bash/pwsh-sandbox/README.i18n.yaml +++ b/packages/bash/pwsh-sandbox/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/bash/pwsh-sandbox/README.md -README.md: bd506d011fa6167ddf7d6fe0565e475979ad0ec2 -README.zh.md: e9aa380302037be3c9dd07331035544299bf3bec +README.md: c47409c122ffce264f53fc41c786da015fdaab6b +README.zh.md: 5b14185a71943d0a0a50f0bae353a3e22d0f1fb2 diff --git a/packages/bash/pwsh-sandbox/README.md b/packages/bash/pwsh-sandbox/README.md index bd506d011f..c47409c122 100644 --- a/packages/bash/pwsh-sandbox/README.md +++ b/packages/bash/pwsh-sandbox/README.md @@ -30,5 +30,5 @@ None directly; the denial surface belongs to the tool layer. ## Known Limitations and Deferred Work - **Reads are unrestricted** on Windows (the ACL runner restricts writes only); the read boundary is documented in `@deepseek-ai/dsh-sandbox-windows-acl`. -- **The Windows workspace-write temp area is the real temp directory** (`GetTempPathW`). This is a deliberate backend-defined choice, the same decision Landlock makes (`readWrite: ['/tmp', ...]`): the seam's "backend-defined temp area" permits it, and the escape probe in `tests/acl.e2e.ts` lives outside the temp tree for exactly that reason. A per-run private temp (bwrap's `--tmpfs /tmp` semantics) would additionally need an environment-block rewrite in the runner; it is an optional future hardening, not a correctness gap. -- **Windows read-only is strict zero-grant** — not even the NUL device is writable; `> $null` redirection still works (documented in the backend package). +- **Windows workspace-write temp authority is private** per live session/workspace pair; agentless calls receive a fresh private directory per invocation. The ambient temp root is never granted, and the runner rewrites TMP/TEMP to the private directory before spawning. +- **Windows read-only grants no explicit writable root but remains partial** because the restricted token must retain Everyone. Objects whose DACL grants Everyone write access — including compatible opens of the NUL device — remain ambient authority; PowerShell's `> $null` redirection still works without opening NUL. diff --git a/packages/bash/pwsh-sandbox/README.zh.md b/packages/bash/pwsh-sandbox/README.zh.md index e9aa380302..5b14185a71 100644 --- a/packages/bash/pwsh-sandbox/README.zh.md +++ b/packages/bash/pwsh-sandbox/README.zh.md @@ -30,5 +30,5 @@ ## 已知限制与后续工作 - **Windows 上读不受限**(ACL runner 只限写);读边界文档在 `@deepseek-ai/dsh-sandbox-windows-acl`。 -- **Windows workspace-write 的临时区域是真实临时目录**(`GetTempPathW`)。这是有意为之的后端自定义选择,与 Landlock 的决策(`readWrite: ['/tmp', ...]`)同类:seam 的 "backend-defined temp area" 词汇表允许它,`tests/acl.e2e.ts` 的逃逸探针也正是因此位于 temp 树之外。按运行创建私有临时目录(bwrap `--tmpfs /tmp` 的语义)还需 runner 改写环境块——这是可选的进一步加固,而非正确性缺口。 -- **Windows read-only 是严格零授权**——连 NUL 设备都不可写;`> $null` 重定向不受影响(后端包有文档)。 +- **Windows workspace-write 的临时权限按每个活跃的会话/工作区对私有**;无 agent(智能体)的调用每次都获得一个新的私有目录。环境临时根目录绝不会被授权,runner 会在 spawn 前将 TMP/TEMP 重写为该私有目录。 +- **Windows read-only 不授予任何显式可写根目录,但仍为部分强制执行**,因为受限令牌必须保留 Everyone。DACL 向 Everyone 授予写访问的对象——包括以兼容方式打开的 NUL 设备——仍构成环境权限来源;PowerShell 的 `> $null` 重定向仍可工作,且不会打开 NUL。 diff --git a/packages/bash/pwsh-sandbox/package.json b/packages/bash/pwsh-sandbox/package.json index 6f2a87fc58..53e418f401 100644 --- a/packages/bash/pwsh-sandbox/package.json +++ b/packages/bash/pwsh-sandbox/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-pwsh-sandbox", "description": "Sandbox-consuming implementation of the DeepSeek Harness PowerShell executor seam (confines every command via ctx.sandbox, reports denial/enforcement result facts)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bash/pwsh-sandbox" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,12 +32,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-pwsh-local": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-pwsh-local": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", @@ -40,6 +47,6 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/pwsh-sandbox/src/index.ts b/packages/bash/pwsh-sandbox/src/index.ts index 7a930b7f0e..ec45571b76 100644 --- a/packages/bash/pwsh-sandbox/src/index.ts +++ b/packages/bash/pwsh-sandbox/src/index.ts @@ -12,7 +12,7 @@ * @module @deepseek-ai/dsh-pwsh-sandbox */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { BashExecRequest, BashExecSpec, BashProcess, BashRunResult } from '@deepseek-ai/dsh-bash' import { SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { diff --git a/packages/bash/pwsh-sandbox/src/invariant.ts b/packages/bash/pwsh-sandbox/src/invariant.ts index 6afda519ff..9d229d1f0d 100644 --- a/packages/bash/pwsh-sandbox/src/invariant.ts +++ b/packages/bash/pwsh-sandbox/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-pwsh-sandbox' diff --git a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts index bb6f4f25e4..6f3644e6e7 100644 --- a/packages/bash/pwsh-sandbox/tests/acl.e2e.ts +++ b/packages/bash/pwsh-sandbox/tests/acl.e2e.ts @@ -2,9 +2,9 @@ * Real-backend end-to-end: LocalSandboxProvider (win32 chain → the * windows-acl runner), SandboxPolicyService, and SandboxPwshExecutor with * REAL pwsh spawns confined through the runner — the debug-instance - * verification of both modes: read-only denies every write (not even NUL), - * workspace-write allows the workspace and temp while denying escape writes, - * and denial/classification facts ride the settled result. + * verification of both modes on ordinary user-owned paths: read-only denies + * writes, workspace-write allows its promised roots while denying escape + * writes, and the partial-enforcement/denial facts ride the settled result. */ import { spawnSync } from 'node:child_process' @@ -12,7 +12,7 @@ import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node: import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SandboxExecutionPolicy } from '@deepseek-ai/dsh-sandbox' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' @@ -29,21 +29,19 @@ function pwshAvailable(): boolean { describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement', () => { let scratchRoot!: string let writableDir!: string - let isolatedTemp!: string + let outsideTempDir!: string let secretFile!: string let escapeFile!: string let executor!: SandboxPwshExecutor beforeAll(async () => { - // The escape probe must live OUTSIDE every legitimately granted tree: the - // provider's workspace-write grants the workspace plus the REAL temp dir - // (the 'backend-defined temp area', same as Landlock granting /tmp), so a - // scratch dir under temp would inherit the grant and the probe would be a - // false pass. A mkdtemp under the profile is removed by afterAll. + // The workspace escape sits under the profile. A separate directory under + // the ambient temp root proves that the root itself is not granted: the + // runner creates its own private child and rewrites TMP/TEMP to it. scratchRoot = mkdtempSync(join(homedir(), 'dsh-pwsh-sandbox-e2e-')) writableDir = join(scratchRoot, 'writable') mkdirSync(writableDir) - isolatedTemp = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-temp-')) + outsideTempDir = mkdtempSync(join(tmpdir(), 'dsh-pwsh-sandbox-e2e-outside-temp-')) secretFile = join(scratchRoot, 'secret.txt') writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') escapeFile = join(scratchRoot, 'escaped.txt') @@ -58,15 +56,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement afterAll(() => { rmSync(scratchRoot, { recursive: true, force: true }) - rmSync(isolatedTemp, { recursive: true, force: true }) + rmSync(outsideTempDir, { recursive: true, force: true }) }) - it('read-only: every write denied (workspace, temp, NUL), reads fine, denial facts ride the result', async () => { + it('read-only: ordinary path writes denied, reads fine, partial and denial facts ride the result', async () => { const policy: SandboxExecutionPolicy = { mode: 'read-only', workspaceRoot: writableDir } const probe = [ "$ErrorActionPreference='SilentlyContinue';", `try{Set-Content -Path '${writableDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, - `try{Set-Content -Path '${isolatedTemp}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + `try{Set-Content -Path '${outsideTempDir}\\ro-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, ].join('') @@ -78,7 +76,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement expect(result.stdout.text).toContain('SECRET-READ: OK') expect(existsSync(join(writableDir, 'ro-write.txt'))).toBe(false) // A self-caught denial keeps the command exit 0: no denial fact. - expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'full' }) + expect(result.sandbox).toEqual({ mode: 'read-only', denied: false, enforcement: 'partial' }) // A raw failing write must classify as a denial of the ACL dialect. const denied = await executor.run(executor.resolve({ @@ -86,26 +84,34 @@ describe.skipIf(!isWin32 || !pwshAvailable())('pwsh-sandbox real ACL confinement sandboxPolicy: policy, })) expect(denied.exitCode).not.toBe(0) - expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'full' }) + expect(denied.sandbox).toEqual({ mode: 'read-only', denied: true, enforcement: 'partial' }) }, 60_000) - it('workspace-write: workspace and temp writable, escape denied, reads fine', async () => { + it('workspace-write: workspace and private temp writable, ambient temp and escape denied', async () => { const policy: SandboxExecutionPolicy = { mode: 'workspace-write', workspaceRoot: writableDir } const probe = [ "$ErrorActionPreference='SilentlyContinue';", `try{Set-Content -Path '${writableDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, - `try{Set-Content -Path '${isolatedTemp}\\ww-write.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + "try{Set-Content -Path (Join-Path $env:TEMP 'ww-write.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};", + `try{Set-Content -Path '${outsideTempDir}\\ww-write.txt' -Value ok -ErrorAction Stop;'AMBIENT-TEMP-WRITE: OK'}catch{'AMBIENT-TEMP-WRITE: DENIED'};`, `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK'}catch{'ESCAPE-WRITE: DENIED'};`, - `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'}`, + `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, + "'TEMP-PATH: ' + $env:TEMP", ].join('') const result = await executor.run(executor.resolve({ command: probe, sandboxPolicy: policy })) expect(result.exitCode, `stderr: ${result.stderr.text}`).toBe(0) expect(result.stdout.text).toContain('TARGET-WRITE: OK') expect(result.stdout.text).toContain('TEMP-WRITE: OK') + expect(result.stdout.text).toContain('AMBIENT-TEMP-WRITE: DENIED') expect(result.stdout.text).toContain('ESCAPE-WRITE: DENIED') expect(result.stdout.text).toContain('SECRET-READ: OK') expect(existsSync(join(writableDir, 'ww-write.txt'))).toBe(true) + expect(existsSync(join(outsideTempDir, 'ww-write.txt'))).toBe(false) expect(existsSync(escapeFile)).toBe(false) - expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'full' }) + const privateTemp = result.stdout.text.match(/^TEMP-PATH: (.+)$/mu)?.[1]?.trim() + expect(privateTemp).toBeDefined() + expect(privateTemp?.startsWith(tmpdir())).toBe(true) + expect(existsSync(privateTemp ?? '')).toBe(false) + expect(result.sandbox).toEqual({ mode: 'workspace-write', denied: false, enforcement: 'partial' }) }, 60_000) }) diff --git a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts index d710801004..fc4cd295a7 100644 --- a/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts +++ b/packages/bash/pwsh-sandbox/tests/sandbox.spec.ts @@ -11,7 +11,7 @@ import { chmodSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, RunnerFailureRule, SandboxExecutionPolicy, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index 2e304ae913..9da6eb65ca 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-task and sandbox-escalation support", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bash/tool-bash" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,21 +32,21 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-bash-env": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tasks": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -61,6 +68,6 @@ "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/tool-bash/src/index.ts b/packages/bash/tool-bash/src/index.ts index 69dae2915a..378b286ac7 100644 --- a/packages/bash/tool-bash/src/index.ts +++ b/packages/bash/tool-bash/src/index.ts @@ -8,8 +8,8 @@ * @module @deepseek-ai/dsh-tool-bash */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { isAbsolute, resolve as resolvePath } from 'node:path' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' diff --git a/packages/bash/tool-bash/src/invariant.ts b/packages/bash/tool-bash/src/invariant.ts index 0620f0cfa9..a4ce0c34eb 100644 --- a/packages/bash/tool-bash/src/invariant.ts +++ b/packages/bash/tool-bash/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash' diff --git a/packages/bash/tool-bash/tests/integration.spec.ts b/packages/bash/tool-bash/tests/integration.spec.ts index 99510dc6e5..425fa0644f 100644 --- a/packages/bash/tool-bash/tests/integration.spec.ts +++ b/packages/bash/tool-bash/tests/integration.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { existsSync, mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/bash/tool-bash/tests/tools.spec.ts b/packages/bash/tool-bash/tests/tools.spec.ts index 9a1698ac1d..f03de1ca93 100644 --- a/packages/bash/tool-bash/tests/tools.spec.ts +++ b/packages/bash/tool-bash/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { mkdtempSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { BashExecutor } from '@deepseek-ai/dsh-bash' import type { BashExecRequest, BashExecSpec, BashProcess, BashProcessRead, BashRunResult } from '@deepseek-ai/dsh-bash' diff --git a/packages/bash/tool-pwsh/README.i18n.yaml b/packages/bash/tool-pwsh/README.i18n.yaml index b6e043fd84..04e314ac14 100644 --- a/packages/bash/tool-pwsh/README.i18n.yaml +++ b/packages/bash/tool-pwsh/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/bash/tool-pwsh/README.md -README.md: 3fd5a53946e2b101d6ef4457e312e52f4db5f3a8 -README.zh.md: c06b4354b6973a6ff196cda7c49966c7c40e0a90 +README.md: 4126e718c569f17fb8be465351b2576970e93c63 +README.zh.md: aba669733a7ecb9924287abb298bb9a154b5afa7 diff --git a/packages/bash/tool-pwsh/README.md b/packages/bash/tool-pwsh/README.md index 3fd5a53946..4126e718c5 100644 --- a/packages/bash/tool-pwsh/README.md +++ b/packages/bash/tool-pwsh/README.md @@ -120,7 +120,7 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work -- **ConstrainedLanguage and named-pipe capture under the Windows sandbox** — when the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) confines a call (read-only or workspace-write), the restricted token puts pwsh into ConstrainedLanguage mode: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. The same modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. +- **Language mode and named-pipe capture under the Windows sandbox** — under the [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md), read-only pwsh starts in ConstrainedLanguage because its temp write denial makes PowerShell's AppLocker probe fail closed: `Add-Type`, non-core .NET statics (`[System.IO.*]::`, `[math]::`), COM objects, and reflection fail with "only core types" errors, and the mode cannot be lifted from inside. Workspace-write's private temp lets the probe complete, so it stays in FullLanguage unless host policy says otherwise. Both confined modes deny named-pipe opens, so a piped-stdio spawn inside a confined command fails with EPERM. The tool description teaches both contracts to the model; the backend README owns the full limitations. - **No persistent shell or PTY** — every call starts a fresh `pwsh -Command`; the PTY backends are Linux/macOS-only today, and a Windows ConPTY persistent shell is roadmap work. - **PowerShell-dialect contract** — the model must write PowerShell (native paths, `$env:` variables), not bash; there is no dialect translation. - **Session-cwd identity is not canonicalized** — the workdir base is the session header cwd as-is, unlike the bash tool's sandbox-root-canonicalized identity. Under a confining executor the policy's workspace root IS canonicalized (by the shared policy service), so the workdir and the confinement root can diverge when the raw session cwd differs from its canonical form — a parity gap deferred to the shared shell-tool base extraction. diff --git a/packages/bash/tool-pwsh/README.zh.md b/packages/bash/tool-pwsh/README.zh.md index c06b4354b6..aba669733a 100644 --- a/packages/bash/tool-pwsh/README.zh.md +++ b/packages/bash/tool-pwsh/README.zh.md @@ -120,7 +120,7 @@ ack 是固定短行;任务输出按读取有界。 ## Known Limitations and Deferred Work -- **Windows sandbox 下的 ConstrainedLanguage 与 named-pipe 捕获** — 当 [Windows ACL sandbox](../../sandbox/sandbox-windows-acl/README.md) 隔离某次调用(read-only 或 workspace-write)时,受限令牌使 pwsh 进入 ConstrainedLanguage 模式:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。这两种模式同样会拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 +- **Windows 沙箱下的语言模式与 named-pipe 捕获** — 在 [Windows ACL 沙箱](../../sandbox/sandbox-windows-acl/README.md) 下,read-only pwsh 会以 ConstrainedLanguage 启动,因为临时目录写入被拒绝,导致 PowerShell 的 AppLocker 探针失败并按 fail-closed 处理:`Add-Type`、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`)、COM 对象与反射都会以“only core types”错误失败,且该模式无法从内部解除。workspace-write 的私有临时目录使探针得以完成,因此除非主机策略另有规定,否则它保持 FullLanguage。两种受限模式都拒绝 named-pipe 打开,因此受限命令内的管道 stdio spawn 以 EPERM 失败。工具描述把这两个约定教给模型;后端 README 负责完整的限制说明。 - **无持久 shell 或 PTY** — 每次调用都启动全新的 `pwsh -Command`;PTY 后端目前仅限 Linux/macOS,Windows ConPTY 持久 shell 属于路线图工作。 - **PowerShell 方言约定** — 模型必须写 PowerShell(原生路径、`$env:` 变量),而不是 bash;没有方言翻译。 - **会话 cwd 身份不做规范化** — workdir 基座直接取会话头 cwd 原值,不同于 bash 工具经 sandbox-root 规范化的身份。在隔离执行器下,策略的工作区根**会**被规范化(由共享的策略服务完成),因此当原始会话 cwd 与其规范化形态不同时,workdir 与隔离根可能不一致——这一 parity 差距留待共享 shell 工具基座提取时解决。 diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json index 042166b493..0b93e820c2 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bash/tool-pwsh" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,21 +32,21 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-bash-env": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tasks": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -58,6 +65,6 @@ "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/tool-pwsh/src/index.ts b/packages/bash/tool-pwsh/src/index.ts index 9ef6650748..acc5e94890 100644 --- a/packages/bash/tool-pwsh/src/index.ts +++ b/packages/bash/tool-pwsh/src/index.ts @@ -20,8 +20,8 @@ */ import { isAbsolute, resolve as resolvePath } from 'node:path' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { defineTool, TOOL_ABORTED } from '@deepseek-ai/dsh-tools' import type { GenericCallView, TerminalCallView, ToolExecution, ToolResult, ToolResultView } from '@deepseek-ai/dsh-tools' import { HarnessError } from '@deepseek-ai/dsh-llm' @@ -114,18 +114,18 @@ function pwshDescription(backgroundEnabled: boolean, escalationModes: readonly S + 'On Windows a force-killed command settles as `[exit code: 1]` without a signal marker — treat it as an interruption, not a command failure. ' + background if (escalationModes.length === 0) return base - // The CLM and named-pipe contracts below are Windows-restricted-token + // The language-mode and named-pipe contracts below are Windows-restricted-token // behavior, but the gate is 'any confining executor is mounted' // (escalationModes non-empty). The conflation is safe today because every // shipped composition pairing tool-pwsh with a confining executor is // win32-only; a future POSIX pwsh-sandbox composition must gate both // sentences on the platform instead (tracked in the pwsh-tool-and-executor // Agent Note). - return base + ' Under the Windows sandbox, pwsh runs in PowerShell ConstrainedLanguage mode (read-only and ' - + 'workspace-write): prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + return base + ' Under the Windows sandbox, read-only pwsh runs in PowerShell ConstrainedLanguage mode, while ' + + 'workspace-write stays in FullLanguage unless host policy says otherwise. In read-only, prefer cmdlets and core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`); ' + '.NET static calls (`[System.IO.*]::`, `[math]::`), `Add-Type`, COM objects, and reflection fail ' + 'with "only core types" errors. `-f` formatting, property access, and core cmdlets work. ' - + 'In the same modes, programs cannot open named pipes, so a command that captures another ' + + 'In both confined modes, programs cannot open named pipes, so a command that captures another ' + 'program\'s output through piped stdio (Node.js `child_process.spawn`/`exec` with the default ' + '`stdio: \'pipe\'`) fails with EPERM, while `stdio: \'inherit\'` and `stdio: \'ignore\'` spawns ' + 'work and PowerShell\'s own pipelines are unaffected. That EPERM is the documented boundary: ' diff --git a/packages/bash/tool-pwsh/src/invariant.ts b/packages/bash/tool-pwsh/src/invariant.ts index dd6370b490..aa53743ba7 100644 --- a/packages/bash/tool-pwsh/src/invariant.ts +++ b/packages/bash/tool-pwsh/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pwsh' diff --git a/packages/bash/tool-pwsh/tests/integration.spec.ts b/packages/bash/tool-pwsh/tests/integration.spec.ts index c347866f50..d2a89468ff 100644 --- a/packages/bash/tool-pwsh/tests/integration.spec.ts +++ b/packages/bash/tool-pwsh/tests/integration.spec.ts @@ -14,7 +14,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { spawnSync } from 'node:child_process' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED } from '@deepseek-ai/dsh-tools' diff --git a/packages/bash/tool-pwsh/tests/tools.spec.ts b/packages/bash/tool-pwsh/tests/tools.spec.ts index 7ecdbcd5f2..75e7208347 100644 --- a/packages/bash/tool-pwsh/tests/tools.spec.ts +++ b/packages/bash/tool-pwsh/tests/tools.spec.ts @@ -11,7 +11,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve as resolvePath } from 'node:path' @@ -559,7 +559,8 @@ describe('sandbox escalation through ctx.approval', () => { expect(properties['sandbox_permissions']?.enum).toEqual(['workspace-write', 'danger-full-access']) expect(schema.description).toContain('approval prompt') expect(schema.description).toContain('ConstrainedLanguage') - expect(schema.description).toContain('named pipes') + expect(schema.description).toContain('workspace-write stays in FullLanguage') + expect(schema.description).toContain('In both confined modes, programs cannot open named pipes') expect(schema.description).toContain('fails with EPERM') for (const args of [ diff --git a/packages/boot/README.i18n.yaml b/packages/boot/README.i18n.yaml index 66d3b7b63c..0de587115e 100644 --- a/packages/boot/README.i18n.yaml +++ b/packages/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/boot/README.md -README.md: 5e4e483b60adab0b22ddb5279f4cd8fb699b9c35 -README.zh.md: 95a3f98129a7d1fdfaffb3cac6fed77bab7cff56 +README.md: 79d653260ea4a9d9a4c71a593b41a6a7e17efa14 +README.zh.md: 839be164328ef168cd6ac18bf2f1dcb930dfce3e diff --git a/packages/boot/README.md b/packages/boot/README.md index 5e4e483b60..79d653260e 100644 --- a/packages/boot/README.md +++ b/packages/boot/README.md @@ -7,5 +7,6 @@ The channel-neutral boot library the app bins share: `apps/cli`, the [`scaffold/ | Package | Role | ctx key | |---|---|---| | `app-boot/` | Shared boot glue for the app bins: `.env` loading, fail-loud Loader guards, snapshot-aware config resolution, the settle-the-tree boot sequence | (library for the bins) | +| `cmdline/` | Launcher-to-app command-line handoff and app-owned startup parsing | `cmdlineArgs`, `appExit` | -The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md). +The boot sequence and personal-config contract are documented in [`app-boot/README.md`](app-boot/README.md); app-owned command lines are documented in [`cmdline/README.md`](cmdline/README.md). diff --git a/packages/boot/README.zh.md b/packages/boot/README.zh.md index 95a3f98129..839be16432 100644 --- a/packages/boot/README.zh.md +++ b/packages/boot/README.zh.md @@ -7,5 +7,6 @@ | 包 | 职责 | ctx 键 | |---|---|---| | `app-boot/` | app bin 的共享启动粘合层:加载 `.env`、会明确报错的 Loader 保护机制、感知快照的配置解析,以及等待整棵树停稳的启动序列 | (供各 bin 使用的库) | +| `cmdline/` | 启动器到应用的命令行交接,以及由应用持有的启动解析 | `cmdlineArgs`、`appExit` | -启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md)。 +启动序列与个人配置约定见 [`app-boot/README.md`](app-boot/README.md);由应用持有的命令行见 [`cmdline/README.md`](cmdline/README.md)。 diff --git a/packages/boot/app-boot/README.i18n.yaml b/packages/boot/app-boot/README.i18n.yaml index cec63092de..efd6a9c844 100644 --- a/packages/boot/app-boot/README.i18n.yaml +++ b/packages/boot/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/boot/app-boot/README.md -README.md: be03bceb39935fafb7acc7d3a99c1fe3af686f94 -README.zh.md: 10165486712fc078cdf1f4147522397a15c88955 +README.md: 9639f1c0a2ffe91fd509a2ffdf04be5f0895b700 +README.zh.md: e8bf0374aad2be2311b6e72e91e41f02f403b48a diff --git a/packages/boot/app-boot/README.md b/packages/boot/app-boot/README.md index be03bceb39..9639f1c0a2 100644 --- a/packages/boot/app-boot/README.md +++ b/packages/boot/app-boot/README.md @@ -15,10 +15,10 @@ Shared boot glue for the app bins ([`dsh`](../../../apps/cli/README.md) and [`ds | `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 | | `loadOptionalPatches(binName, file)` | Parse an optional patch-list file (a profile's `cordis.patch.yml`) — 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 top-level YAML array containing the same include `PatchOptions` entries described above; a missing file also throws because the caller named it | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR | +| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | Register the statically imported `cordis:include` and `cordis:group` builtins, mount the include, and retain the exact root entry used by user patch-layer HMR; an optional module base anchors bare package names to the installed host while relative names stay config-relative | | `watchUserPatches(ctx, options)` | Register the named patch file 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 user layer) and returns an async disposer | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile machinery (see [Profiles](#profiles)) | -| `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), 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 | +| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | 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), 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; the optional module base has the same resolution semantics as `mountRootInclude` | | `renderConfigDump(binName, absoluteConfigPath, layers, warn?)` | Compose the base config and labeled overlay layers offline with 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 rows that shares one source file and the same patch layers is preceded by a `# ==` comment naming that file and those layers, keeping the output one loadable document; a patch matching no row goes to `warn` with its layer label (default: one stderr line), and read, parse, or field validation 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 | @@ -29,7 +29,7 @@ The Loader mounts entries concurrently, so a surface can already own the termina `cordis:group` is registered beside `cordis:include` so a composition can give one `isolate` realm to a provider and its consumers together. Both load through the ambient module pipeline rather than the included tree's own specifier resolution, which is what lets a composition outside this workspace — an agent preset under the Harness home — use a group row at all. -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 shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. +Bare plugin specifiers in a config (`@deepseek-ai/dsh-*`, npm packages) resolve through the Cordis Loader's internal module loader. They resolve from the config directory by default; a closed runtime passes `bareModuleBaseUrl` to `boot` or `mountRootInclude` so its installed package tree remains authoritative even when the config lives inside another Node project. Relative specifiers always resolve against the config directory. 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. 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 `pnpm dsh` source path additionally maps manifest-declared workspace packages to their TypeScript source; its configuration gate requires every shipped raw/Web bare plugin to appear in the resolver manifest's `dependencies`. This package carries no loader hooks and no dev-mode surface. The [`dsh` app](../../../apps/cli/README.md) owns its Node source-launch hook and consumes these helpers for the boot sequence; built consumers continue to use plain Node package resolution. @@ -42,7 +42,7 @@ User-level machine-local preferences also live in the Harness home: - **`.env`** — the product CLI's ordinary environment layers: the invoking directory's file outranks the Harness-home file, and both sit below the inherited environment. `loadLayeredEnv` snapshots each value's source, rejects [bootstrap-only file variables](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision) case-insensitively, and materializes accepted values into `process.env` for Loader expressions and third-party libraries. Managed credentials live separately in [`.credentials.yaml`](../../credentials/credentials-local/README.md); a credential left in either `.env` remains a lower-priority fallback. - **`cordis.patch.yml`** (home level) and **`profiles//cordis.patch.yml`** — the user patch layers, applied after every bundle layer (per-profile first, then the home-level file, which therefore outranks it): 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 composed tree is a stderr warning. An empty or comments-only file throws (it parses to nothing, not to a list); disable the layer with `[]`. -Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlay/flag 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. +Long-lived surfaces keep `cordis.patch.yml` live through `watchUserPatches`; one-shot runs read only the startup value. The watcher targets the exact path even when the file or immediate parent does not exist, serializes bursts, and recomposes the user patches inside the caller's layer order (bundle layers below, overlays 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. ## Model Experience diff --git a/packages/boot/app-boot/README.zh.md b/packages/boot/app-boot/README.zh.md index 1016548671..e8bf0374aa 100644 --- a/packages/boot/app-boot/README.zh.md +++ b/packages/boot/app-boot/README.zh.md @@ -15,10 +15,10 @@ | `assertEntriesActivated(ctx, binName)` | 先执行 `assertEntriesLoaded` 检查,再在 Loader 结算后等待每个已启用配置项;抛出的错误包含每个失败插件的原始错误堆栈,或每个等待中插件尚未解析的服务 | | `loadOptionalPatches(binName, file)` | 解析一份可选的 patch 列表文件(即 profile 的 `cordis.patch.yml`):其顶层是一个 YAML 数组,内容为 include 的 `PatchOptions`(按 id 定位的配置覆盖、`insert` 列表,允许 `!!js`);文件不存在时返回 `undefined`,文件不可读、不可解析或内容不是数组时抛出异常 | | `loadOverlayPatches(binName, file)` | 解析必需的顶层 YAML 数组,其中包含与上文相同的 include `PatchOptions` 条目;文件缺失也会抛出异常,因为该文件是调用方指名的 | -| `mountRootInclude(ctx, absoluteConfigPath, patches?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项 | +| `mountRootInclude(ctx, absoluteConfigPath, patches?, bareModuleBaseUrl?)` | 注册静态导入的 `cordis:include` 与 `cordis:group` builtin,挂载 include,并保留用户 patch 层 HMR(热模块替换)使用的确切根配置项;可选模块基准会把裸包名锚定到已安装宿主,而相对名称仍以配置目录为基准 | | `watchUserPatches(ctx, options)` | 向现有 Cordis HMR 服务注册指名的 patch 文件;每次新增、变更或移除都会通过调用方的 `compose` 闭包(应用自有层围绕当前用户层)以事务方式重新组合完整 patch 列表,并返回异步清理函数 | | `resolveProfileDir` / `initProfile` / `loadProfile` / `readProfileManifest` / `writeProfileManifest` / `resolveBundleDir` / `composeEntries` / `healProfilesModuleFallback` / `PROFILE_TEMPLATES` / `DEFAULT_PROFILE_BUNDLES` / `PROFILES_DIR` / `PROFILE_PATCH_FILENAME` | Profile 机制(见 [Profile](#profiles)) | -| `boot(binName, absoluteConfigPath, patches?, prepare?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject | +| `boot(binName, absoluteConfigPath, patches?, prepare?, bareModuleBaseUrl?)` | 创建根上下文,向 Loader `!!js` 配置表达式暴露 `dshHomePath(...segments)` 并安装 Loader,在配置树条目挂载前执行可选的宿主准备操作(`prepare` 可以使用 Loader,也可以提供由启动器拥有的上下文插槽),再挂载并等待 include 树结算,断言所有条目均已加载并激活,最后返回根上下文——失败时 dispose(资源释放)部分构造的上下文,并以带标签的错误 reject;可选模块基准与 `mountRootInclude` 的解析语义相同 | | `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` 注册使用 | @@ -29,7 +29,7 @@ Loader 并发挂载各个条目,因此当其他环节失败时,某个界面 `cordis:group` 与 `cordis:include` 一并注册,使一份组装能把一个提供方与它的消费方放进同一个 `isolate` realm。两者都通过宿主的模块管线加载,而非被包含树自身的说明符解析,这正是让本工作区之外的组装——放在 Harness home 下的 agent preset——能够使用 group 行的原因。 -配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。相对 specifier 无需原生 helper,并以配置目录为基准解析。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`dsh` 源码启动器还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 +配置中的裸插件 specifier(`@deepseek-ai/dsh-*`、npm 包)通过 Cordis Loader 的内部模块 loader 解析。默认情况下,它们从配置目录解析;封闭运行时会向 `boot` 或 `mountRootInclude` 传入 `bareModuleBaseUrl`,使已安装包树保持权威,即使配置位于另一个 Node 项目中也不受遮蔽。相对 specifier 始终以配置目录为基准解析。仓库 bin 会安装 Loader 的可选 peer `node-addon-require-builtin`;外部调用方必须提供该组件,或者把插件安装到普通 Node import 解析可以找到的位置。构建后的 `dsh-app-boot` 产物内嵌静态挂载的 Include 实现,但仍将 Loader 保持为外部依赖,因此 include 树与宿主会绑定到同一个 Loader peer。`pnpm dsh` 源码路径还会将 manifest(元数据清单)声明的 workspace 包映射到其 TypeScript 源码;其配置门禁要求每个随附的原始/Web 裸插件都出现在解析所用 manifest 的 `dependencies` 中。 此包不包含 loader 钩子,也不提供开发模式接口。[`dsh` 应用](../../../apps/cli/README.md) 持有自己的 Node 源码启动钩子,并在启动序列中使用这些 helper;构建后的消费方仍使用普通 Node 包解析。 @@ -42,7 +42,7 @@ profile 是位于 `$DSH_HOME/profiles/` 下的目录(Harness home 由 [` - **`.env`**:产品 CLI 的普通环境层;调用目录的文件优先于 Harness home 的文件,两者都低于继承环境。`loadLayeredEnv` 记录每个值的来源,按不区分大小写的方式拒绝 [bootstrap-only 文件变量](../../../.agents/notes/implemented/architecture/2026-08-04-configuration-source-ownership.md#decision),并把其余值物化进 `process.env`,供 Loader 表达式和第三方库使用。受管凭据另存于 [`.credentials.yaml`](../../credentials/credentials-local/README.md);留在任一 `.env` 中的凭据仍是低优先级后备值。 - **`cordis.patch.yml`**(home 级)与 **`profiles//cordis.patch.yml`**:用户 patch 层,应用在所有组合包层之后(先应用逐 profile 的文件,再应用 home 级文件,因此后者优先级更高):按 id 定位的 patch 会替换对应条目的整个 `config`(未改字段也要重述),`insert` 会添加条目,`!!js` 表达式则在挂载时插值。如果 patch 指定的条目 id 不在组合后的树中,则输出一条 stderr 警告。空文件或仅含注释的文件会抛出异常(其解析结果为空,而不是列表);如需禁用该层,请使用 `[]`。 -长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay/标志 patch 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 +长期运行的界面会持续应用 `cordis.patch.yml` 的变更,具体由 `watchUserPatches` 负责;一次性运行只读取启动时的值。即使该文件或其直接父目录不存在,监视器仍会监视确切路径;它会串行处理突发变更,并按调用方的层次顺序重新组合用户 patch(组合包层在下、overlay 在上)。读取失败、解析失败或 Loader 候选被拒时,最后一个可用树会继续运行;HMR 服务记录错误后广播 `hmr/config-update-failed(filename, Error)`,并隔离观察方的失败。上下文 dispose 时会关闭 watcher,并等待进行中的刷新结束。 ## 模型体验 diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index c33dc878b8..f6d4cfc07d 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-app-boot", "description": "Shared boot glue for the app bins: .env loading, fail-loud Loader guards, snapshot-aware config resolution, and the Loader boot sequence", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/boot/app-boot" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -28,32 +35,32 @@ "js-yaml": "^4.2.0" }, "peerDependencies": { - "@cordisjs/plugin-group": "^1.0.0", - "@cordisjs/plugin-hmr": "^1.0.15", - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-environment": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-group": "workspace:^", + "@deepseek-ai/cordis-plugin-hmr": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "peerDependenciesMeta": { - "@cordisjs/plugin-hmr": { + "@deepseek-ai/cordis-plugin-hmr": { "optional": true } }, "devDependencies": { - "@cordisjs/plugin-group": "workspace:^", - "@cordisjs/plugin-hmr": "workspace:^", - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", - "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/cordis-plugin-group": "workspace:^", + "@deepseek-ai/cordis-plugin-hmr": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@types/js-yaml": "^4.0.9", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/boot/app-boot/src/index.ts b/packages/boot/app-boot/src/index.ts index fa23e6f8da..94d76dea81 100644 --- a/packages/boot/app-boot/src/index.ts +++ b/packages/boot/app-boot/src/index.ts @@ -9,19 +9,19 @@ import { pathToFileURL } from 'node:url' import { readFileSync } from 'node:fs' import { parseEnv } from 'node:util' -import { basename, dirname, resolve } from 'node:path' +import { basename, dirname, isAbsolute, resolve } from 'node:path' import * as yaml from 'js-yaml' -import { Context, type FiberState } from 'cordis' -import Loader, { type Entry, type EntryOptions } from '@cordisjs/plugin-loader' -import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@cordisjs/plugin-include' -import Group from '@cordisjs/plugin-group' +import { Context, type FiberState } from '@deepseek-ai/cordis' +import Loader, { type Entry, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import Include, { applyEntryPatches, entryListSchema, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import Group from '@deepseek-ai/cordis-plugin-group' import { dshHomePath, resolveDshHome } from '@deepseek-ai/dsh-paths' import { createEnvironmentSnapshot, type EnvironmentSnapshot } from '@deepseek-ai/dsh-environment' -import type {} from '@cordisjs/plugin-hmr' +import type {} from '@deepseek-ai/cordis-plugin-hmr' // Side-effect type import: resolves `ctx.get('systemPrompt')` to the service. import type {} from '@deepseek-ai/dsh-system-prompt' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Harness-home path resolver available to Loader `!!js` config expressions. */ dshHomePath?: typeof dshHomePath @@ -199,7 +199,7 @@ export function loadLayeredEnv( 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 +// Loader interpolates against each entry's injection-ready context), imported // from the include itself so patch parsing and config dumping can never drift // from what the include mounts. User patch layers share it so they may // reference `process.env`. @@ -215,7 +215,7 @@ export interface UserPatchWatchOptions { * Compose the full patch list for a fresh user-layer generation — * the same composition the app booted with, so a reload can interleave the * new user patches between app-owned layers (bundle layers below, - * overlay/flag patches above). Identity when omitted: the user layer + * overlays above). Identity when omitted: the user layer * is the whole patch list. */ compose?: (userPatches: PatchOptions[]) => PatchOptions[] @@ -265,7 +265,7 @@ export async function watchUserPatches( /** * Load an optional patch-list file: a top-level YAML array of loader patch - * entries (`@cordisjs/plugin-include`'s `PatchOptions`): id-targeted config + * entries (`@deepseek-ai/cordis-plugin-include`'s `PatchOptions`): id-targeted config * overrides and `insert` lists, with `!!js` expressions allowed. A missing * file means "no layer"; an unreadable, unparsable, or non-array file throws — * a present patch file that cannot apply is a misconfiguration and must fail @@ -305,7 +305,7 @@ export function loadOverlayPatches(binName: string, file: string): PatchOptions[ } /** * Parse one loader patch list: a top-level YAML array of - * `@cordisjs/plugin-include` `PatchOptions` (id-targeted config overrides and + * `@deepseek-ai/cordis-plugin-include` `PatchOptions` (id-targeted config overrides and * `insert` lists, `!!js` expressions allowed). Every invalid field or value throws, * because a patch file that cannot be applied at all is a misconfiguration; a * single patch whose target row is absent stays a per-entry Loader warning, so @@ -476,6 +476,8 @@ function groupedDump( * @param ctx - context carrying an initialized Loader service. * @param absoluteConfigPath - absolute YAML or JSON configuration path. * @param patches - initial app and user patches, applied in order. + * @param bareModuleBaseUrl - optional installed-host base for bare package + * names; relative names continue to resolve beside the configuration file. * @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. @@ -484,24 +486,38 @@ export async function mountRootInclude( ctx: Context, absoluteConfigPath: string, patches: readonly PatchOptions[] = [], + bareModuleBaseUrl?: string, ): Promise { - ctx.loader.builtins.include = Include + ctx.loader.builtins.include = bareModuleBaseUrl === undefined + ? Include + : class HostResolvedRootInclude extends Include { + override import(name: string, getOuterStack?: () => string[]): unknown { + const specifier = isAbsolute(name) ? pathToFileURL(name).href : name + if (name.startsWith('.') || name.startsWith('cordis:')) return super.import(specifier, getOuterStack) + const internal = this.ctx.loader.internal + /* v8 ignore next -- Node supplies the internal loader; this preserves the + original diagnostic for hypothetical embedders without it. */ + if (internal === undefined) return super.import(specifier, getOuterStack) + return internal.import(specifier, bareModuleBaseUrl, {}) + } + } // `cordis:group` alongside it: a group row is how a composition gives one // `isolate` realm to a provider and its consumers together, and an agent - // preset living outside this workspace cannot resolve `@cordisjs/plugin-group` + // preset living outside this workspace cannot resolve `@deepseek-ai/cordis-plugin-group` // by name. Both builtins load through the ambient module pipeline, so neither // depends on the included tree's own specifier resolution. ctx.loader.builtins.group = Group // 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 includeConfig: Include.Config = { + path: pathToFileURL(absoluteConfigPath).href, + ...patches.length > 0 ? { patches: [...patches] } : {}, + } const rootInclude: EntryOptions = { id: 'include', name: 'cordis:include', - config: { - path: pathToFileURL(absoluteConfigPath).href, - ...patches.length > 0 ? { patches: [...patches] } : {}, - }, + config: includeConfig, } const includeId = await ctx.loader.create(rootInclude) const loader = ctx.get('loader') @@ -709,14 +725,13 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro /** * Boot the Loader against `absoluteConfigPath` and return only after the whole - * tree settles. Entry names load through the Loader's internal module loader - * against `baseUrl` (the config directory), which may live outside - * `node_modules` reach and, unbuilt, cannot load vendored source; the - * bootstrap include is therefore statically imported and mounted as the - * `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. Loader + * tree settles. Relative entry names resolve against the config directory; + * bare package names resolve there by default or against an explicit + * `bareModuleBaseUrl` for closed packaged runtimes. The bootstrap include + * is statically imported and mounted as the `cordis:include` builtin, loading + * through the ambient module pipeline (vite/tsx/plain ESM). The package build + * embeds Include while leaving Loader 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 @@ -729,6 +744,9 @@ export async function assertEntriesActivated(ctx: Context, binName: string): Pro * @param patches - optional overlay patches applied over the included tree * (see {@link loadOptionalPatches}); an empty list mounts none. * @param prepare - optional host setup run after Loader installation and before any config-tree entry mounts. + * @param bareModuleBaseUrl - optional installed-host base for bare package + * names; use it when the host, rather than the configuration project, owns the + * complete plugin set. * @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 error after disposing the partial context — `host @@ -740,6 +758,7 @@ export async function boot( absoluteConfigPath: string, patches?: PatchOptions[], prepare?: (ctx: Context) => Promise | void, + bareModuleBaseUrl?: string, ): Promise { const ctx = new Context() // Two failure labels: `prepare` runs before any config-tree entry mounts, @@ -751,7 +770,7 @@ export async function boot( await ctx.plugin(Loader) await prepare?.(ctx) stage = 'plugin tree failed to load' - await mountRootInclude(ctx, absoluteConfigPath, patches) + await mountRootInclude(ctx, absoluteConfigPath, patches, bareModuleBaseUrl) // A surface can finish and dispose the whole tree while startup is still // in flight, before the last entry settles. The Loader service goes with // it, and the activation audit describes a live tree — reading `ctx.loader` diff --git a/packages/boot/app-boot/src/invariant.ts b/packages/boot/app-boot/src/invariant.ts index 0dacba6e40..8195ecb553 100644 --- a/packages/boot/app-boot/src/invariant.ts +++ b/packages/boot/app-boot/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-app-boot' diff --git a/packages/boot/app-boot/src/profile.ts b/packages/boot/app-boot/src/profile.ts index 486d414749..e19bb13c41 100644 --- a/packages/boot/app-boot/src/profile.ts +++ b/packages/boot/app-boot/src/profile.ts @@ -27,8 +27,8 @@ import { existsSync, lstatSync, mkdirSync, readFileSync, readlinkSync, rmSync, symlinkSync, writeFileSync, } from 'node:fs' import { basename, dirname, join } from 'node:path' -import type { EntryOptions } from '@cordisjs/plugin-loader' -import { applyEntryPatches, type PatchOptions } from '@cordisjs/plugin-include' +import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { applyEntryPatches, type PatchOptions } from '@deepseek-ai/cordis-plugin-include' import { resolveDshHome } from '@deepseek-ai/dsh-paths' import { loadOverlayPatches } from './index.ts' diff --git a/packages/boot/app-boot/tests/app-boot.spec.ts b/packages/boot/app-boot/tests/app-boot.spec.ts index baeb98fe77..8bbb8fddfa 100644 --- a/packages/boot/app-boot/tests/app-boot.spec.ts +++ b/packages/boot/app-boot/tests/app-boot.spec.ts @@ -1,8 +1,9 @@ -import { mkdtempSync, mkdirSync, writeFileSync } from 'node:fs' +import { mkdtempSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' +import { pathToFileURL } from 'node:url' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { addHarnessSourceSection, assertEntriesActivated, assertEntriesLoaded, boot, @@ -557,6 +558,73 @@ describe('boot', () => { } }) + it('can resolve bare plugins from the harness when the config project shadows their package name', async () => { + const dir = tmp() + const harness = tmp() + const absolutePlugin = join(dir, 'absolute.mjs') + const shadow = join(dir, 'node_modules', '@deepseek-ai', 'dsh-system-prompt') + const harnessPlugin = join(harness, 'node_modules', '@deepseek-ai', 'dsh-system-prompt') + mkdirSync(shadow, { recursive: true }) + mkdirSync(harnessPlugin, { recursive: true }) + writeFileSync(join(shadow, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-system-prompt', + type: 'module', + exports: './index.mjs', + })) + writeFileSync(join(shadow, 'index.mjs'), [ + 'export function apply(ctx) {', + ' ctx.provide("shadowPluginLoaded", true)', + '}', + '', + ].join('\n')) + writeFileSync(join(harnessPlugin, 'package.json'), JSON.stringify({ + name: '@deepseek-ai/dsh-system-prompt', + type: 'module', + exports: './index.mjs', + })) + writeFileSync(join(harnessPlugin, 'index.mjs'), [ + 'export function apply(ctx) {', + ' ctx.provide("harnessPluginLoaded", true)', + '}', + '', + ].join('\n')) + writeFileSync(join(dir, 'relative.mjs'), 'export function apply(ctx) { ctx.provide("relativePluginLoaded", true) }\n') + writeFileSync(absolutePlugin, 'export function apply(ctx) { ctx.provide("absolutePluginLoaded", true) }\n') + const entries = [ + '- id: prompt', + " name: '@deepseek-ai/dsh-system-prompt'", + '- id: relative', + " name: './relative.mjs'", + ] + const configOwnedPath = join(dir, 'config-owned.cordis.yml') + writeFileSync(configOwnedPath, [...entries, ''].join('\n')) + const hostOwnedPath = join(dir, 'host-owned.cordis.yml') + writeFileSync(hostOwnedPath, [ + ...entries, + '- id: absolute', + ` name: ${JSON.stringify(absolutePlugin)}`, + '', + ].join('\n')) + const configOwned = await boot(NAME, configOwnedPath) + try { + expect(configOwned.get('shadowPluginLoaded')).toBe(true) + expect(configOwned.get('systemPrompt')).toBeUndefined() + expect(configOwned.get('relativePluginLoaded')).toBe(true) + } finally { + await configOwned.fiber.dispose() + } + const harnessBaseUrl = pathToFileURL(join(harness, 'entry.mjs')).href + const ctx = await boot(NAME, hostOwnedPath, undefined, undefined, harnessBaseUrl) + try { + expect(ctx.get('harnessPluginLoaded')).toBe(true) + expect(ctx.get('shadowPluginLoaded')).toBeUndefined() + expect(ctx.get('relativePluginLoaded')).toBe(true) + expect(ctx.get('absolutePluginLoaded')).toBe(true) + } finally { + await ctx.fiber.dispose() + } + }) + it('runs host preparation before the Loader tree mounts', async () => { const dir = tmp() writeFileSync(join(dir, 'noop.mjs'), 'export const name = "noop"\nexport function apply() {}\n') @@ -631,7 +699,18 @@ describe('boot', () => { '}', '', ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: exiting\n name: ./exiting.mjs\n') + writeFileSync(join(dir, 'delayed.mjs'), [ + 'await new Promise(resolve => setTimeout(resolve, 10))', + 'export function apply() {}', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: exiting', + ' name: ./exiting.mjs', + '- id: delayed', + ' name: ./delayed.mjs', + '', + ].join('\n')) const ctx = await boot(NAME, join(dir, 'cordis.yml')) expect(ctx.get('loader')).toBeUndefined() }) @@ -644,6 +723,25 @@ describe('boot', () => { ) }) + it('labels a deferred config failure with its row and leaves the source file unchanged', async () => { + const dir = tmp() + const configPath = join(dir, 'cordis.yml') + const config = [ + '- id: invalid-config', + ' name: ./noop.mjs', + ' config:', + ' value: !!js "JSON.parse(\'invalid\')"', + '', + ].join('\n') + writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') + writeFileSync(configPath, config) + + await expect(boot(NAME, configPath)).rejects.toThrow( + 'failed to apply loader entry invalid-config (./noop.mjs)', + ) + expect(readFileSync(configPath, 'utf8')).toBe(config) + }) + it('appends the deepest cause with its original stack to the load failure', async () => { const dir = tmp() writeFileSync(join(dir, 'failing.mjs'), [ diff --git a/packages/boot/app-boot/tests/config-dump.spec.ts b/packages/boot/app-boot/tests/config-dump.spec.ts index 3876ec2421..ed0117b0dc 100644 --- a/packages/boot/app-boot/tests/config-dump.spec.ts +++ b/packages/boot/app-boot/tests/config-dump.spec.ts @@ -12,7 +12,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' import * as yaml from 'js-yaml' -import { entryListSchema } from '@cordisjs/plugin-include' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { loadOverlayPatches, renderConfigDump } from '../src/index.ts' const NAME = 'dsh-test-bin' diff --git a/packages/boot/app-boot/tests/config-reload.spec.ts b/packages/boot/app-boot/tests/config-reload.spec.ts index 9cbe3d1a58..ca718a65b3 100644 --- a/packages/boot/app-boot/tests/config-reload.spec.ts +++ b/packages/boot/app-boot/tests/config-reload.spec.ts @@ -8,8 +8,8 @@ import { mkdtempSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import type { Include } from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import type { Include } from '@deepseek-ai/cordis-plugin-include' import { boot } from '../src/index.ts' const NAME = 'dsh-test-bin' @@ -391,7 +391,7 @@ describe('shipped builtins', () => { it('lets a booted composition share one isolate realm across a group of rows', async () => { // The reason `boot()` registers `cordis:group`: a composition — notably an // agent preset living outside this workspace, which cannot resolve - // `@cordisjs/plugin-group` by name — gives a provider and its consumer one + // `@deepseek-ai/cordis-plugin-group` by name — gives a provider and its consumer one // named realm so the service stays out of the root realm while remaining // visible to the rows that need it. const { ctx } = await bootTree([ diff --git a/packages/boot/app-boot/tests/hmr-config.spec.ts b/packages/boot/app-boot/tests/hmr-config.spec.ts index 82cbd71c28..c248643130 100644 --- a/packages/boot/app-boot/tests/hmr-config.spec.ts +++ b/packages/boot/app-boot/tests/hmr-config.spec.ts @@ -3,10 +3,10 @@ import { realpath } from 'node:fs/promises' 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 { Context } from '@deepseek-ai/cordis' +import Hmr from '@deepseek-ai/cordis-plugin-hmr' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Timer from '@deepseek-ai/cordis-plugin-timer' import { describe, expect, it, vi } from 'vitest' async function bootHmr(dir: string, root: string[] = [], usePolling?: boolean): Promise { diff --git a/packages/boot/app-boot/tests/repository-cache.spec.ts b/packages/boot/app-boot/tests/repository-cache.spec.ts deleted file mode 100644 index 71aa90a440..0000000000 --- a/packages/boot/app-boot/tests/repository-cache.spec.ts +++ /dev/null @@ -1,218 +0,0 @@ -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 { delimiter, 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[] = [] - -/** Normalize Git's platform checkout line endings for source-content assertions. */ -const lf = (text: string): string => text.replace(/\r\n/g, '\n') - -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, { install: 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, { install: 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, { install: 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, { install: async () => { throw new Error('must not reinstall') } }) - - await expect(cache.resolve(specifier)).rejects.toThrow('repository cache marker is invalid') - }) - - it('isolates and prepares a .dsh-plugin Git subpath from an enclosing pnpm workspace', { timeout: 60_000 }, async () => { - const root = await temporaryRoot('repository-pnpm') - const repository = join(root, 'source') - await mkdir(join(repository, '.dsh-plugin'), { recursive: true }) - await mkdir(join(repository, '.dsh-plugin', 'build-helper'), { recursive: true }) - await mkdir(join(repository, '.dsh-plugin', 'prepare-helper'), { recursive: true }) - await mkdir(join(repository, 'skills', 'fixture'), { recursive: true }) - const shadowPnpm = join(root, 'shadow-pnpm') - await mkdir(shadowPnpm) - await writeFile(join(shadowPnpm, 'pnpm'), '#!/bin/sh\nexit 99\n', { mode: 0o700 }) - await writeFile(join(shadowPnpm, 'pnpm.bat'), '@exit /b 99\r\n') - await writeFile(join(repository, 'package.json'), `${JSON.stringify({ - name: 'repository-fixture', - private: true, - version: '1.0.0', - packageManager: `pnpm@${BUNDLED_PNPM_VERSION}`, - })}\n`) - await writeFile(join(repository, 'pnpm-workspace.yaml'), 'packages: []\n') - await writeFile(join(repository, 'pnpm-lock.yaml'), [ - "lockfileVersion: '9.0'", - 'settings:', - ' autoInstallPeers: true', - ' excludeLinksFromLockfile: false', - 'importers:', - ' .: {}', - '', - ].join('\n')) - await writeFile(join(repository, '.dsh-plugin', 'build-helper', 'package.json'), `${JSON.stringify({ - name: 'repository-build-helper', - version: '1.0.0', - bin: 'index.js', - })}\n`) - await writeFile(join(repository, '.dsh-plugin', 'build-helper', 'index.js'), [ - '#!/usr/bin/env node', - "require('node:fs').writeFileSync('dependency-built.txt', 'dependency available\\n')", - '', - ].join('\n'), { mode: 0o700 }) - await writeFile(join(repository, '.dsh-plugin', 'prepare-helper', 'package.json'), `${JSON.stringify({ - name: 'repository-prepare-helper', - version: '1.0.0', - bin: { 'dsh-plugin-prepare': 'index.js' }, - })}\n`) - await writeFile(join(repository, '.dsh-plugin', 'prepare-helper', 'index.js'), [ - '#!/usr/bin/env node', - "const { cpSync, mkdirSync, writeFileSync } = require('node:fs')", - "mkdirSync('dsh-plugin-assets/skills', { recursive: true })", - "cpSync('../skills', 'dsh-plugin-assets/skills/0', { recursive: true })", - "writeFileSync('dsh-plugin.mjs', 'export function apply() {}\\n')", - "writeFileSync('prepared.txt', `${process.env.REPOSITORY_TEST_VISIBLE ?? 'absent'}|${process.env.REPOSITORY_TEST_TOKEN ?? 'absent'}|${process.env.PNPM_CONFIG_IGNORE_WORKSPACE ?? 'absent'}\\n`)", - "writeFileSync('environment.json', `${JSON.stringify({ path: process.env.PATH, pathExt: process.env.PATHEXT })}\\n`)", - '', - ].join('\n'), { mode: 0o700 }) - 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: { - // The fixture owns dependency installation, not platform-specific - // node_modules/.bin shim generation during pnpm's Git preparation. - prepack: [ - 'node ./node_modules/repository-build-helper/index.js', - 'node ./node_modules/repository-prepare-helper/index.js', - ].join(' && '), - }, - devDependencies: { - 'repository-build-helper': 'file:./build-helper', - 'repository-prepare-helper': 'file:./prepare-helper', - }, - dsh: { skills: ['../skills'] }, - })}\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()}&path:/.dsh-plugin` - vi.stubEnv('REPOSITORY_TEST_VISIBLE', 'visible') - vi.stubEnv('REPOSITORY_TEST_TOKEN', 'hidden') - vi.stubEnv('PNPM_HOME', shadowPnpm) - vi.stubEnv('PATH', [shadowPnpm, ...(process.env.PATH === undefined ? [] : [process.env.PATH])].join(delimiter)) - vi.stubEnv('PATHEXT', '.BAT;.CMD;.EXE') - - const installed = await new RepositoryCache(join(root, 'cache')).resolve(specifier) - await expect(readFile(join(installed, 'dependency-built.txt'), 'utf8')).resolves.toBe('dependency available\n') - await expect(readFile(join(installed, 'prepared.txt'), 'utf8')).resolves.toBe('visible|absent|true\n') - const environment = JSON.parse(await readFile(join(installed, 'environment.json'), 'utf8')) as { - path: string - pathExt: string - } - expect(environment.path.split(delimiter)).not.toContain(shadowPnpm) - expect(environment.pathExt.split(';')[0]?.toUpperCase()).toBe('.CMD') - await expect(readFile(join(installed, 'dsh-plugin.mjs'), 'utf8')).resolves.toContain('export function apply') - expect(lf(await readFile(join(installed, 'dsh-plugin-assets/skills/0/fixture/SKILL.md'), 'utf8'))) - .toBe('repository skill source\n') - await expect(readFile(join(installed, 'package.json'), 'utf8')) - .resolves.toContain('repository-plugin-fixture') - }) -}) diff --git a/packages/boot/app-boot/tests/user-patches.spec.ts b/packages/boot/app-boot/tests/user-patches.spec.ts index 333385ee50..da58524e1d 100644 --- a/packages/boot/app-boot/tests/user-patches.spec.ts +++ b/packages/boot/app-boot/tests/user-patches.spec.ts @@ -9,10 +9,11 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Hmr from '@cordisjs/plugin-hmr' -import Loader from '@cordisjs/plugin-loader' -import Timer from '@cordisjs/plugin-timer' +import { Context } from '@deepseek-ai/cordis' +import Hmr from '@deepseek-ai/cordis-plugin-hmr' +import Include, { type PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Timer from '@deepseek-ai/cordis-plugin-timer' import { boot, loadOptionalPatches, @@ -92,23 +93,101 @@ describe('loadOptionalPatches', () => { }) }) -describe('boot with user patches', () => { - function writeTree(dir: string): string { - writeFileSync(join(dir, 'noop.mjs'), [ - 'export const name = "noop"', - 'export function apply(_ctx, config = {}) {', - ' if (config.fail) throw new Error("candidate config failed")', - '}', +function writeTree(dir: string): string { + 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') +} + +function entryConfig(ctx: Context, id: string): unknown { + return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config +} + +describe('Loader config interpolation', () => { + it("resolves Include's own !!js options", async () => { + const dir = tmp() + writeFileSync(join(dir, 'noop.mjs'), 'export function apply() {}\n') + writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n') + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + ctx.provide('includePath', pathToFileURL(join(dir, 'cordis.yml')).href) + try { + await ctx.loader.create({ + name: 'cordis:include', + config: { path: { __jsExpr: "ctx.get('includePath')" } }, + }) + await ctx.loader.await() + expect([...ctx.loader.entries()].some(entry => entry.options.id === 'noop')).toBe(true) + } finally { + await ctx.fiber.dispose() + } + }) + + it('waits for row injections before resolving !!js and resolves again after provider replacement', async () => { + const dir = tmp() + writeFileSync(join(dir, 'provider.mjs'), [ + 'export const name = "provider"', + 'export function apply(ctx, config) { ctx.provide("phaseOne", config) }', '', ].join('\n')) - writeFileSync(join(dir, 'cordis.yml'), '- id: noop\n name: ./noop.mjs\n config:\n value: base\n') - return join(dir, 'cordis.yml') - } + writeFileSync(join(dir, 'reader.mjs'), [ + 'export const name = "reader"', + 'export const inject = ["phaseOne"]', + 'export function apply(ctx, config) { ctx.provide("readerResult", config) }', + '', + ].join('\n')) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const composition: PatchOptions[] = [{ + insert: [ + { + // Consumer-first order proves interpolation follows injection + // readiness rather than YAML position. + id: 'reader', + name: './reader.mjs', + inject: ['phaseOne'], + config: { value: { __jsExpr: 'ctx.phaseOne.fail ? (() => { throw new Error("rejected provider") })() : ctx.phaseOne.value' } }, + }, + { id: 'provider', name: './provider.mjs', config: { value: 'first' } }, + ], + }] + const ctx = await boot(NAME, join(dir, 'cordis.yml'), composition) + try { + expect(ctx.get('readerResult')).toEqual({ value: 'first' }) + const provider = [...ctx.loader.entries()].find(entry => entry.options.id === 'provider') + expect(provider).toBeDefined() + await provider?.update({ disabled: true }) + await ctx.loader.await() + expect(ctx.get('readerResult')).toBeUndefined() + await provider?.update({ config: { value: 'second' } }) + await provider?.update({ disabled: false }) + await ctx.loader.await() + expect(ctx.get('readerResult')).toEqual({ value: 'second' }) - function entryConfig(ctx: Context, id: string): unknown { - return [...ctx.loader.entries()].find(entry => entry.options.id === id)?.options.config - } + await provider?.update({ disabled: true }) + await provider?.update({ config: { fail: true } }) + await provider?.update({ disabled: false }) + await expect(ctx.loader.await()).rejects.toThrow('rejected provider') + expect(ctx.get('readerResult')).toBeUndefined() + await provider?.update({ disabled: true }) + await provider?.update({ config: { value: 'recovered' } }) + await provider?.update({ disabled: false }) + await ctx.loader.await() + expect(ctx.get('readerResult')).toEqual({ value: 'recovered' }) + } finally { + await ctx.fiber.dispose() + } + }) +}) + +describe('boot with user patches', () => { it('applies id-targeted overrides, inserts, and interpolates !!js from the environment', async () => { const dir = tmp() const userDir = tmp() diff --git a/packages/boot/app-boot/tsdown.config.ts b/packages/boot/app-boot/tsdown.config.ts index 88492d7c26..6693770892 100644 --- a/packages/boot/app-boot/tsdown.config.ts +++ b/packages/boot/app-boot/tsdown.config.ts @@ -14,6 +14,6 @@ export default defineConfig({ dts: false, clean: false, deps: { - alwaysBundle: ['@cordisjs/plugin-include'], + alwaysBundle: ['@deepseek-ai/cordis-plugin-include'], }, }) diff --git a/packages/experimental/README.i18n.yaml b/packages/boot/cmdline/README.i18n.yaml similarity index 56% rename from packages/experimental/README.i18n.yaml rename to packages/boot/cmdline/README.i18n.yaml index 48cfed37ee..6a032582cf 100644 --- a/packages/experimental/README.i18n.yaml +++ b/packages/boot/cmdline/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 packages/experimental/README.md -README.md: db39af8bb1b1bcfd257e16e4ad1dd112f604ffb1 -README.zh.md: fc5942190a354668164b41b99e83b52b14d88f18 +# pnpm run verify-translation-pairing --write packages/boot/cmdline/README.md +README.md: 98335e901bdf8fe33e14c1ad4c1a320d77f30c96 +README.zh.md: 28ea749943c60089c6b4725cb61e121f82aa0114 diff --git a/packages/boot/cmdline/README.md b/packages/boot/cmdline/README.md new file mode 100644 index 0000000000..98335e901b --- /dev/null +++ b/packages/boot/cmdline/README.md @@ -0,0 +1,74 @@ +# `@deepseek-ai/dsh-cmdline` + +English | [中文](README.zh.md) + +The command line a dsh launcher hands to the app it boots. The launcher parses only its own flags (`--profile`, `--patch`, the config dumps) and hands **everything after them** to the tree verbatim, so an app owns its flag family, its `--help` text, and its parse errors instead of the launcher knowing them. + +## The launcher values + +A launcher calls `provideCmdline(ctx, host)` before any tree entry mounts, which provides: + +- `ctx.cmdlineArgs` — the invocation's inner arguments. `get()` is the whole interface, and it returns a snapshot: `dsh --profile tui --resume abc` yields `['--resume', 'abc']`. +- `ctx.appExit` — a bounded process-exit request, wired to the launcher's shutdown controller. + +An embedding host with no command line provides an empty list; that is the honest answer, not a missing value. + +## Ordinary providers and injected config + +Any app plugin may inject `cmdlineArgs`, parse it, and publish an ordinary app-owned service. `parseCmdline(ctx, program, plan)` is only a commander adapter; the caller owns the returned value and service: + +```ts ignore +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] + +export function apply(ctx: Context): void { + const values = parseCmdline(ctx, webCommand(), planWebStartup) + if (values !== undefined) ctx.provide('webStartup', values) +} +``` + +Its Loader row carries no launcher marker or special kind: + +```yaml +- id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' +``` + +Every row configured from those values uses ordinary service injection and direct lazy config access: + +```yaml +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] + config: + host: !!js ctx.webStartup.host ?? '127.0.0.1' + port: !!js ctx.webStartup.port ?? 3080 +``` + +`parseCmdline` parses the immutable arguments and asks `plan` for the app-owned value. On `--help`, `--version`, a parse error, or a `program.error(...)` from the plan, it writes commander's text, requests exit, and returns `undefined`; the provider publishes nothing, so dependent rows never activate. + +### How injection orders config + +Loader defers a row's `!!js` interpolation until that row's declared injections are active, then evaluates against the row's plugin context. The example above can therefore read `ctx.webStartup` directly: Cordis has already populated that injected service before Loader asks for `webserver`'s config. Include trees preserve nested expression nodes until each target row reaches this point. Provider replacement and live patch reload repeat interpolation against the current injected services, so a launch flag cannot be silently reset. + +`enableRow(ctx, id)` turns on a row a bundle ships disabled because only some invocations want it (`dsh web --dev` and its client-plugin reload chain). The activation is an in-memory override: it does not rewrite the row's configured `disabled` value and survives config reapplication for that mounted entry. Loader applies the enabled row's ordinary injection ordering. + +### Shared immutable arguments + +`get()` does not consume or mutate argv. Multiple plugins can parse the same snapshot and independently provide services. The launcher does not inspect the composition for a command-line owner; a profile with no reader simply ignores its app arguments. + +An out-of-tree plugin brings its own commander copy, so commander's control-flow errors are detected structurally rather than by class identity; an identity check would rethrow a printed help as a fatal load failure. + +## Model Experience + +None, as this package resolves the process's own command line before any session exists. + +#### KV Cache effect + +None; this package neither assembles nor sends a provider request. + +## Known Limitations and Deferred Work + +- **Launcher flags must precede app arguments.** The split is positional: the first token the launcher does not recognize starts the inner arguments, so `--patch` placed after an app flag belongs to the app. The launcher's parser consumes one `--`, so an app argument that must survive as a literal `--` needs `-- --`. +- **An app-owned service has no statically declared provider.** Consumer rows name it through ordinary injection; a bundle that omits its provider fails at settlement with pending entries naming the service rather than at load. +- **A user patch that replaces a row's whole `config` drops its expressions.** A flag beats the value written beside it, not a literal a user wrote in place of the expression; keeping the expression is what keeps the flag winning. diff --git a/packages/boot/cmdline/README.zh.md b/packages/boot/cmdline/README.zh.md new file mode 100644 index 0000000000..28ea749943 --- /dev/null +++ b/packages/boot/cmdline/README.zh.md @@ -0,0 +1,74 @@ +# `@deepseek-ai/dsh-cmdline` + +[English](README.md) | 中文 + +dsh 启动器交给它所引导应用的那条命令行。启动器只解析属于自己的 flag(`--profile`、`--patch`、配置 dump),并把**其后的一切**原样交给配置树,因此 flag 家族、`--help` 文本和解析错误都由应用自己持有,启动器不必知道它们。 + +## 启动器提供的值 + +启动器在任何配置树条目挂载之前调用 `provideCmdline(ctx, host)`,它提供: + +- `ctx.cmdlineArgs`:本次调用的内层参数。`get()` 就是它的全部接口,返回一份快照:`dsh --profile tui --resume abc` 得到 `['--resume', 'abc']`。 +- `ctx.appExit`:一个有边界的进程退出请求,接到启动器的关停控制器上。 + +没有命令行的嵌入宿主提供空列表;这是诚实的答案,而不是缺失的值。 + +## 普通提供方与注入配置 + +任何应用插件都可以注入 `cmdlineArgs`、解析它,再发布一个普通的应用自有服务。`parseCmdline(ctx, program, plan)` 只适配 commander;返回值与服务都归调用方持有: + +```ts ignore +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] + +export function apply(ctx: Context): void { + const values = parseCmdline(ctx, webCommand(), planWebStartup) + if (values !== undefined) ctx.provide('webStartup', values) +} +``` + +它的 Loader 行不携带启动器标记,也没有特殊类型: + +```yaml +- id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' +``` + +所有由这些取值配置的行都使用普通服务注入,并在惰性配置中直接访问该服务: + +```yaml +- id: webserver + name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] + config: + host: !!js ctx.webStartup.host ?? '127.0.0.1' + port: !!js ctx.webStartup.port ?? 3080 +``` + +`parseCmdline` 解析不可变参数,再向 `plan` 索取应用自有取值。遇到 `--help`、`--version`、解析错误,或 `plan` 发出的 `program.error(...)` 时,它输出 commander 文本、请求退出并返回 `undefined`;提供方什么也不发布,因此依赖行不会激活。 + +### 注入如何排列配置求值 + +Loader 会把一行的 `!!js` 插值推迟到该行声明的注入全部激活之后,再基于该行的插件上下文求值。所以上例可以直接读取 `ctx.webStartup`:Loader 索取 `webserver` 的配置之前,Cordis 已经填入了这个注入服务。Include 树会保留嵌套表达式节点,直到各个目标行到达这一时点。提供方替换与活动 patch 重载都会针对当前注入服务重新插值,因此启动 flag 不会被悄悄重置。 + +`enableRow(ctx, id)` 打开某个组合包以禁用状态交付、只有部分调用才需要的行(`dsh web --dev` 及其客户端插件重载链路)。该激活是内存中的覆盖:它不会改写行所配置的 `disabled` 值,并会在已挂载条目的配置重新应用后继续生效。Loader 会对启用后的行应用普通的注入顺序。 + +### 共享不可变参数 + +`get()` 不会消费或修改 argv。多个插件可以解析同一份快照,并分别提供服务。启动器不会检查组合中的命令行所有者;没有读取方的 profile 只会忽略自己的应用参数。 + +树外插件会带来自己的一份 commander 副本,因此 commander 的控制流错误按结构识别,而不是按类身份识别;按身份判断会把已经打印出来的 help 重新抛成致命的加载失败。 + +## 模型体验 + +无。本包在任何会话存在之前解析进程自身的命令行。 + +#### KV Cache 影响 + +无;本包既不组装也不发送提供方请求。 + +## 已知限制与延期工作 + +- **启动器的 flag 必须写在应用参数之前**:切分按位置进行,启动器不认识的第一个 token 就是内层参数的起点,因此写在某个应用 flag 之后的 `--patch` 属于应用。启动器的解析器会消耗掉一个 `--`,因此必须以字面量 `--` 存活到应用的参数需要写成 `-- --`。 +- **应用自有服务没有静态声明的提供方**:消费行通过普通注入点名它;缺少提供方的组合包会在结算时失败,由待处理条目点名该服务,而不是在加载时失败。 +- **用户 patch 若整体替换某行的 `config`,会连同其中的表达式一起丢掉**:flag 胜过的是表达式旁写着的那个值,而不是用户用字面量替换掉表达式之后的结果;保留表达式才能保留 flag 的优先级。 diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json new file mode 100644 index 0000000000..9b30a9f7c5 --- /dev/null +++ b/packages/boot/cmdline/package.json @@ -0,0 +1,46 @@ +{ + "name": "@deepseek-ai/dsh-cmdline", + "description": "Immutable command-line handoff from a dsh launcher to any app plugin that injects cmdlineArgs", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/boot/cmdline" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.d.ts" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "commander": "^15.0.0", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/boot/cmdline/src/index.ts b/packages/boot/cmdline/src/index.ts new file mode 100644 index 0000000000..fb502cb0e2 --- /dev/null +++ b/packages/boot/cmdline/src/index.ts @@ -0,0 +1,174 @@ +/** + * @deepseek-ai/dsh-cmdline — the command line a dsh launcher hands to the app + * it boots. + * + * The launcher parses only its own flags (`--profile`, `--patch`, the config + * dumps) and hands everything after them to the tree verbatim through the + * {@link CmdlineArgs} service, so an app owns its flag family, its `--help` + * text, and its parse errors instead of the launcher knowing them. + * + * Any app plugin can inject `cmdlineArgs` and call {@link parseCmdline}. A + * provider may publish the parsed values as its own service, and ordinary rows + * can inject that service and read it from lazily resolved config — + * `port: !!js ctx.webStartup.port ?? 3080` — so a flag beats the value written + * beside it. No row has launcher-level command-line status. + * @module @deepseek-ai/dsh-cmdline + */ + +import type { Command } from 'commander' +import type { Context } from '@deepseek-ai/cordis' +// Empty type import carries the Loader Context merge used by enableRow. +import type {} from '@deepseek-ai/cordis-plugin-loader' + +/** + * The invocation's inner arguments: everything after the launcher's own flags, + * verbatim and in argv order. `dsh --profile tui --resume abc` yields + * `['--resume', 'abc']`. + */ +export interface CmdlineArgs { + /** + * Read the inner arguments. + * @returns the arguments in argv order; empty when the invocation carried none. + */ + get(): readonly string[] +} + +/** Request bounded process exit; the launcher wires it to its shutdown controller. */ +export interface AppExit { + /** + * Request exit once the tree has been disposed. + * @param code - the process exit code. + */ + (code: number): void +} + +declare module '@deepseek-ai/cordis' { + interface Context { + /** The invocation's inner arguments; provided by a launcher before the tree mounts. */ + cmdlineArgs?: CmdlineArgs + /** Bounded process-exit request; provided by a launcher before the tree mounts. */ + appExit?: AppExit + } +} + +/** The launcher facts an app needs. */ +export interface CmdlineHost { + /** The invocation's inner arguments, in argv order. */ + args: readonly string[] + /** Bounded process-exit request. */ + exit: AppExit +} + +/** + * Provide the command line and the exit request on a host context before any + * tree entry mounts. Both are launcher facts, not config: an embedding host + * with no command line provides an empty argument list. + * @param ctx - the host context the tree will mount under. + * @param host - the invocation's arguments and its exit request. + */ +export function provideCmdline(ctx: Context, host: CmdlineHost): void { + const snapshot: readonly string[] = Object.freeze([...host.args]) + ctx.provide('cmdlineArgs', { get: () => snapshot }) + ctx.provide('appExit', host.exit) +} + +/** The process streams commander output is written to; production writes to the process. */ +export const internals: { stdout: { write(chunk: string): unknown }; stderr: { write(chunk: string): unknown } } = { + stdout: process.stdout, + stderr: process.stderr, +} + +/** + * Resolve parsed arguments into an app-owned value. Call + * `program.error(...)` to reject the invocation with a usage message instead + * of throwing. + * @param program - the parsed commander program. + * @param ctx - the plugin context that received the command line. + * @returns the value an ordinary provider plugin may publish. + */ +export type CmdlinePlan = (program: Command, ctx: Context) => T + +/** + * Parse the launcher's immutable argument snapshot with an app's commander + * program. The caller decides whether and how to publish the returned value; + * this helper has no Loader-row or service ownership semantics. + * + * Help, version, and rejected arguments are terminal for the process: commander + * writes the text, the helper requests `ctx.appExit`, and it returns + * `undefined` so the caller publishes nothing. + * @param ctx - plugin context carrying `cmdlineArgs` and `appExit`. + * @param program - the app's commander program, with its flags and description already declared. + * @param plan - this invocation's resolved value; omitted returns an empty object. + * @returns the resolved value, or `undefined` when the app asked to exit. + * @throws when the launcher did not provide the command line and exit request. + */ +export function parseCmdline( + ctx: Context, + program: Command, + plan: CmdlinePlan = (() => ({}) as T), +): T | undefined { + // Read through the global service store, not the property proxy: appExit is + // an optional host value and the plugin only needs to inject cmdlineArgs. + const args = ctx.get('cmdlineArgs') + const exit = ctx.get('appExit') + if (args === undefined || exit === undefined) { + throw new Error(`${program.name()}: the launcher must provide ctx.cmdlineArgs and ctx.appExit before the tree mounts`) + } + program + .exitOverride() + .configureOutput({ + writeOut: text => void internals.stdout.write(text), + writeErr: text => void internals.stderr.write(text), + }) + try { + program.parse(args.get(), { from: 'user' }) + return plan(program, ctx) + } catch (error) { + // exitOverride turns help, version, a parse error, and a plan's own + // program.error() into a CommanderError; commander has already written the + // text through the output configured above. + if (!isCommanderError(error)) throw error + exit(error.exitCode) + return undefined + } +} + +/** + * Turn on a row this composition ships disabled, because this invocation asked + * for it (`dsh web --dev` and its client-plugin reload chain). + * + * A row cannot be inserted from inside a mounting plugin — the Loader returns a + * prefixed id it then fails to resolve — so a conditional row ships disabled + * and a row mounted beside it enables it after startup resolves the invocation. + * The Loader keeps that activation in memory, separate from serialized options, + * so reapplying the composition cannot restore the invocation's row to disabled. + * @param ctx - plugin context whose Loader tree carries the row. + * @param id - the row id. + * @returns nothing once the row has started or is waiting for its dependencies. + * @throws when the Loader or named row is absent. + */ +export async function enableRow(ctx: Context, id: string): Promise { + const loader = ctx.get('loader') + if (loader === undefined) throw new Error('dsh-cmdline: enabling a row requires the Loader service') + const entry = [...loader.entries()].find(candidate => candidate.options.id === id) + if (entry === undefined) throw new Error(`dsh-cmdline: the composition has no ${JSON.stringify(id)} row to enable`) + await entry.enableRuntime() +} + +/** + * Whether a thrown value is commander's own control-flow error (help, version, + * a parse error, or `program.error`). + * + * Detected structurally, not with `instanceof`: an out-of-tree plugin brings + * its own commander copy, whose `CommanderError` class is a different identity + * from this package's, and an identity check there would rethrow a printed + * help as a fatal load failure. + * @param error - the thrown value. + * @returns true when the value carries commander's error code and exit code. + */ +function isCommanderError(error: unknown): error is { code: string; exitCode: number } { + if (typeof error !== 'object' || error === null) return false + const candidate = error as { code?: unknown; exitCode?: unknown } + return typeof candidate.code === 'string' && candidate.code.startsWith('commander.') + && typeof candidate.exitCode === 'number' +} diff --git a/packages/boot/cmdline/src/invariant.ts b/packages/boot/cmdline/src/invariant.ts new file mode 100644 index 0000000000..b18094a1f4 --- /dev/null +++ b/packages/boot/cmdline/src/invariant.ts @@ -0,0 +1,30 @@ +/** + * Package-owned invariant companion for `@deepseek-ai/dsh-cmdline`. + * @module @deepseek-ai/dsh-cmdline/invariant + */ + +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-cmdline' + +/** Cordis companion plugin name. */ +export const name = 'cmdline-invariant' +/** Service required before the companion can register. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: `cmdlineArgs` is an immutable launcher fact that any + * number of ordinary plugins may read. App-owned providers and consumers use + * normal Cordis service injection, whose missing dependencies are already + * reported by Loader settlement. + */ +const install: InvariantInstaller = () => {} + +/** + * Register this package's invariant companion. + * @param ctx - Cordis context carrying the invariant service. + * @returns the installed registration's disposer after setup succeeds. + */ +export const apply = (ctx: Context): Promise<() => void> => + Promise.resolve(ctx.invariants.register(PACKAGE_NAME, install)) diff --git a/packages/boot/cmdline/tests/cmdline.spec.ts b/packages/boot/cmdline/tests/cmdline.spec.ts new file mode 100644 index 0000000000..bc9b63c9aa --- /dev/null +++ b/packages/boot/cmdline/tests/cmdline.spec.ts @@ -0,0 +1,265 @@ +/** + * The launcher-to-app command line over a REAL Loader tree, mounted the way a + * profile boot mounts it: Loader holds each row until its injections are + * active, then resolves that row's config against its injection-ready context. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Command } from 'commander' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include' +import { afterEach, describe, expect, it } from 'vitest' +import { + enableRow, internals, parseCmdline, provideCmdline, type CmdlinePlan, +} from '../src/index.ts' + +/** Every value one boot of the fixture tree observed. */ +interface Observed { + /** Config the reading row started with; absent means it never started. */ + started?: Record + exits: number[] + out: string +} + +/** A booted fixture tree: what it observed, and its root for direct parser calls. */ +interface Fixture { + observed: Observed + ctx: Context +} + +const disposers: (() => Promise)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** The fixture app's flag family: one `--port` its rows read from the service. */ +function demoCommand(): Command { + return new Command().name('demo').exitOverride().option('--port ', 'listen port') +} + +/** The fixture app's plan: the resolved values its rows read. */ +const demoPlan: CmdlinePlan<{ port?: number }> = (program) => { + const port = program.opts<{ port?: string }>().port + if (port === undefined) return {} + if (!/^\d+$/.test(port)) program.error(`error: --port must be a number, got ${JSON.stringify(port)}`) + return { port: Number(port) } +} + +/** A YAML `!!js` expression node, as the include parses one out of a patch file. */ +const expression = (source: string): unknown => ({ __jsExpr: source }) + +/** + * Mount a two-row composition the way a profile boot does: both rows at once, + * with Loader ordering config resolution from their injections. + * @param args - the invocation's inner arguments. + * @param plan - the app's plan; defaults to the fixture's own. + * @returns the booted fixture. + */ +async function bootFixture( + args: string[], + plan: CmdlinePlan = demoPlan, + options: { objectInject?: boolean; withoutProvider?: boolean } = {}, +): Promise { + const dir = mkdtempSync(join(tmpdir(), 'dsh-cmdline-')) + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'reader.mjs'), ` +export const name = 'reader' +export const inject = ['demoStartup'] +export function apply(ctx, config) { globalThis.__observed.started = config } +`) + // The Loader imports a row through Node's own resolver, which cannot resolve + // this workspace's sources; the row delegates to the real function the test + // imported through the source-plane path mapping. + writeFileSync(join(dir, 'startup.mjs'), ` +export const name = 'demo-startup' +export const inject = ['cmdlineArgs'] +export function apply(ctx) { return globalThis.__provideDemoArgs(ctx) } +`) + writeFileSync(join(dir, 'cordis.yml'), '[]\n') + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { __observed: Observed; __provideDemoArgs: (ctx: Context) => void } + globals.__observed = observed + globals.__provideDemoArgs = (ctx: Context) => { + const values = parseCmdline(ctx, demoCommand(), plan) + if (values !== undefined) ctx.provide('demoStartup', values) + } + + // The composition, exactly as a profile delivers one: include patches whose + // config carries `!!js` expressions. + const composition: PatchOptions[] = [{ + insert: [ + ...options.withoutProvider === true + ? [] + : [{ id: 'demo-startup', name: pathToFileURL(join(dir, 'startup.mjs')).href }], + { + id: 'reader', + name: pathToFileURL(join(dir, 'reader.mjs')).href, + inject: options.objectInject === true ? { demoStartup: { required: true } } : ['demoStartup'], + config: { port: expression('ctx.demoStartup.port ?? 3080') }, + }, + ], + }] + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(join(dir, 'cordis.yml')).href, patches: structuredClone(composition) }, + }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return { observed, ctx } +} + +describe('parseCmdline', () => { + it('lets a row read the flag value the app resolved', async () => { + const { observed } = await bootFixture(['--port', '8080']) + expect(observed.started).toEqual({ port: 8080 }) + expect(observed.exits).toEqual([]) + }) + + it('leaves a row on the value written beside the expression when no flag names one', async () => { + const { observed } = await bootFixture([]) + expect(observed.started).toEqual({ port: 3080 }) + }) + + it('recognizes the Loader object form of a provider-service injection', async () => { + const { observed } = await bootFixture(['--port', '8080'], demoPlan, { objectInject: true }) + expect(observed.started).toEqual({ port: 8080 }) + }) + + it('prints the app help, starts no reading row, and requests exit 0', async () => { + const { observed } = await bootFixture(['--help']) + expect(observed.out).toContain('Usage: demo') + expect(observed.started).toBeUndefined() + expect(observed.exits).toEqual([0]) + }) + + it('rejects the invocation from the plan without starting the app', async () => { + const { observed } = await bootFixture(['--port', 'abc']) + expect(observed.out).toContain('--port must be a number') + expect(observed.started).toBeUndefined() + expect(observed.exits).toEqual([1]) + }) + + it('rethrows a plan failure that is not commander asking to exit', async () => { + const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) + const plan: CmdlinePlan = () => { throw new Error('plan exploded') } + expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan exploded') + }) + + it('rethrows a thrown value that is not an object at all', async () => { + const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) + const plan: CmdlinePlan = () => { + const thrown: unknown = 'plan threw a string' + throw thrown + } + expect(() => { parseCmdline(ctx, demoCommand(), plan) }).toThrow('plan threw a string') + }) + + it('returns values without inspecting Loader rows or owning a service', async () => { + const { ctx } = await bootFixture([], demoPlan, { withoutProvider: true }) + expect(parseCmdline(ctx, demoCommand())).toEqual({}) + expect(ctx.get('demoStartup')).toBeUndefined() + }) +}) + +describe('enableRow', () => { + it('enables the named Loader row and fails loud when the Loader or row is absent', async () => { + const withoutLoader = new Context() + await expect(enableRow(withoutLoader, 'client-hmr')).rejects.toThrow('requires the Loader service') + + const ctx = new Context() + let enabled = false + ctx.provide('loader', { + entries: () => [{ + options: { id: 'client-hmr' }, + enableRuntime: async () => { enabled = true }, + }], + } as never) + await enableRow(ctx, 'client-hmr') + expect(enabled).toBe(true) + await expect(enableRow(ctx, 'absent')).rejects.toThrow('no "absent" row to enable') + }) + + it('keeps invocation-only activation through config reapplication', async () => { + const dir = mkdtempSync(join(tmpdir(), 'dsh-runtime-enable-')) + const observed = { starts: 0, stops: 0 } + ;(globalThis as unknown as { __runtimeEnableObserved: typeof observed }).__runtimeEnableObserved = observed + writeFileSync(join(dir, 'conditional.mjs'), ` +export function apply(ctx) { + globalThis.__runtimeEnableObserved.starts += 1 + ctx.effect(() => () => { globalThis.__runtimeEnableObserved.stops += 1 }) +} +`) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: conditional', + ` name: ${pathToFileURL(join(dir, 'conditional.mjs')).href}`, + ' disabled: true', + '', + ].join('\n')) + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(join(dir, 'cordis.yml')).href }, + }) + await ctx.loader.await() + const conditional = [...ctx.loader.entries()].find(entry => entry.options.id === 'conditional') + const include = [...ctx.loader.entries()].find(entry => entry.options.name === 'cordis:include') + expect(conditional).toBeDefined() + expect(include?.fiber).toBeDefined() + expect(conditional?.options.disabled).toBe(true) + expect(observed).toEqual({ starts: 0, stops: 0 }) + + await enableRow(ctx, 'conditional') + await ctx.loader.await() + expect(conditional?.disabled).toBe(false) + expect(conditional?.options.disabled).toBe(true) + expect(observed).toEqual({ starts: 1, stops: 0 }) + + await include!.fiber!.update(include!.options.config, true) + await ctx.loader.await() + expect(conditional?.disabled).toBe(false) + expect(conditional?.options.disabled).toBe(true) + expect(observed).toEqual({ starts: 1, stops: 0 }) + disposers.push(async () => { await ctx.fiber.dispose() }) + }) +}) + +describe('provideCmdline', () => { + it('hands the app a snapshot the caller cannot mutate afterwards', () => { + const ctx = new Context() + const args = ['--resume', 'abc'] + provideCmdline(ctx, { args, exit: () => {} }) + args.push('--tampered') + expect(ctx.cmdlineArgs?.get()).toEqual(['--resume', 'abc']) + }) + + it('fails loud when a parser runs without the launcher values', () => { + const ctx = new Context() + expect(() => { parseCmdline(ctx, demoCommand()) }) + .toThrow('the launcher must provide ctx.cmdlineArgs and ctx.appExit') + }) + + it('lets multiple parsers read the same immutable snapshot', () => { + const ctx = new Context() + provideCmdline(ctx, { args: ['--port', '8080'], exit: () => {} }) + expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) + expect(parseCmdline(ctx, demoCommand(), demoPlan)).toEqual({ port: 8080 }) + expect(Object.isFrozen(ctx.cmdlineArgs?.get())).toBe(true) + }) +}) diff --git a/packages/self-modification/repository-plugin/tsconfig.json b/packages/boot/cmdline/tsconfig.json similarity index 64% rename from packages/self-modification/repository-plugin/tsconfig.json rename to packages/boot/cmdline/tsconfig.json index 67cb0dedf2..f4bcebf1e8 100644 --- a/packages/self-modification/repository-plugin/tsconfig.json +++ b/packages/boot/cmdline/tsconfig.json @@ -8,24 +8,15 @@ "src" ], "references": [ - { - "path": "../../../vendor/cosmokit" - }, { "path": "../../../vendor/cordis" }, + { + "path": "../../../vendor/include" + }, { "path": "../../../vendor/loader" }, - { - "path": "../../skill/skill-local" - }, - { - "path": "../../mcp/mcp-client" - }, - { - "path": "../../util/paths" - }, { "path": "../../support/invariants" } diff --git a/packages/bundle/base/README.i18n.yaml b/packages/bundle/base/README.i18n.yaml index 29786ca332..502cdf16d4 100644 --- a/packages/bundle/base/README.i18n.yaml +++ b/packages/bundle/base/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/bundle/base/README.md -README.md: 2a87b01ad4819750a58163f8c472e61ea633588e -README.zh.md: dc79895355546812aa3371487190724f169c6260 +README.md: 8b0db20274036a2601da19617a35e6bf4aeb30ca +README.zh.md: ac5ab10a523fa211c1c1daf4c55d4dc8702eb782 diff --git a/packages/bundle/base/README.md b/packages/bundle/base/README.md index 2a87b01ad4..8b0db20274 100644 --- a/packages/bundle/base/README.md +++ b/packages/bundle/base/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, repository Plugins, telemetry — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. +The shared dsh core as a profile bundle: [`cordis.patch.yml`](cordis.patch.yml) inserts every base plugin row — model adapters, the shared [`agent-default-model`](../../core/agent-default-model/README.md) selection, tools, persistence, policy, settings/credentials, telemetry, and host-level subagent providers — over the empty profile root, as the first layer of every profile's `dsh.profile.bundles` list. Codex and Claude Code providers load dormant; Agent Presets independently decide whether their agent contributes either model-facing delegation tool. Later bundle layers (e.g. [`dsh-web-app`](../web-app/README.md)) and the user's profile `cordis.patch.yml` override these rows by id; a patch replaces a row's whole `config`, so mode-specific values live in mode bundles, not here. The package has no runtime API; the profile composer resolves the patch through the `dsh.bundle.patch` manifest field, never through code. Windows hosts booting a shipped profile additionally receive [`windows.cordis.patch.yml`](windows.cordis.patch.yml): it disables the POSIX-only bash stack (`bash-sandbox`/`tool-bash`) and inserts the sandbox-confined PowerShell stack (`@deepseek-ai/dsh-pwsh-sandbox`, `@deepseek-ai/dsh-tool-pwsh`). The permission surface stays exactly as on POSIX: `sandbox`/`sandbox-policy` enforce the file-effect policy through the Windows ACL restricted-token runner (the win32 chain of `dsh-sandbox-local` → `@deepseek-ai/dsh-sandbox-windows-acl`), the permission switcher and the approval service run unchanged, and `fs-sandbox` keeps fencing `ctx.fs` writes — mounting `dsh-fs-local` alongside it would double-register `ctx.fs` and fail the load. The launcher applies the layer between the bundle layers and the user layers on win32 hosts; a Windows host that prefers the unconfined local pwsh executor or full access overrides these rows through its profile or home `cordis.patch.yml` (the bash-restore recipe must be complete: disable `pwsh-sandbox`/`tool-pwsh` AND re-enable `bash-sandbox`/`tool-bash` — both executor families register the same `bash` service, so an incomplete recipe fails loud at load). POSIX hosts never receive it. @@ -19,4 +19,5 @@ None directly; each inserted row's package owns its effect. ## Known Limitations and Deferred Work - **A patch replaces whole row configs** — profile overrides must restate every field a row keeps; there is no deep-merge layer. +- **Claude's SDK platform CLI remains in the Profile install closure** — the base bundle depends on the Claude provider, whose production path resolves the host `claude`; removing the SDK's unused optional payload is deferred to the product installation-closure follow-up. - **The Windows temp grant is a private per-session subdirectory** — `workspace-write` confines writes to the workspace plus the session's own temp subdirectory (`\dsh-`, TMP/TEMP rewritten for confined children); `read-only` grants nothing. See `@deepseek-ai/dsh-sandbox-windows-acl`. diff --git a/packages/bundle/base/README.zh.md b/packages/bundle/base/README.zh.md index dc79895355..ac5ab10a52 100644 --- a/packages/bundle/base/README.zh.md +++ b/packages/bundle/base/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、repository 插件、遥测——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 +以 profile 组合包形式交付的共享 dsh 核心:[`cordis.patch.yml`](cordis.patch.yml) 在空的 profile 根之上插入全部基础插件行——模型适配器、共享的 [`agent-default-model`](../../core/agent-default-model/README.md) 选择、工具、持久化、策略、settings/credentials、遥测与宿主级 subagent provider——作为每个 profile 的 `dsh.profile.bundles` 列表中的第一层。Codex 与 Claude Code provider 以休眠状态加载;Agent Preset 分别决定自己的 agent 是否贡献任一面向模型的委派工具。后续的组合包层(例如 [`dsh-web-app`](../web-app/README.md))和用户 profile 的 `cordis.patch.yml` 按 id 覆盖这些行;patch 会替换目标行的整个 `config`,因此模式专属的值放在各模式组合包中,而不是这里。该包没有运行时 API;profile 组合器通过 manifest(元数据清单)的 `dsh.bundle.patch` 字段解析 patch,绝不通过代码。 启动交付 profile 的 Windows 主机还会额外收到 [`windows.cordis.patch.yml`](windows.cordis.patch.yml):它禁用仅 POSIX 的 bash 栈(`bash-sandbox`/`tool-bash`),并插入沙盒受限的 PowerShell 栈(`@deepseek-ai/dsh-pwsh-sandbox`、`@deepseek-ai/dsh-tool-pwsh`)。权限面与 POSIX 完全一致:`sandbox`/`sandbox-policy` 通过 Windows ACL 受限令牌 runner(`dsh-sandbox-local` 的 win32 链 → `@deepseek-ai/dsh-sandbox-windows-acl`)执行文件效果策略,权限切换器与 approval 服务原样运行,`fs-sandbox` 继续围栏 `ctx.fs` 写入——在其旁再挂载 `dsh-fs-local` 会重复注册 `ctx.fs` 并在加载时失败。启动器在 win32 主机上把该层应用于 bundle 层与用户层之间;偏好不限权本地 pwsh 执行器或完整访问的 Windows 主机通过其 profile 或 home 的 `cordis.patch.yml` 覆盖这些行(bash 恢复配方必须完整:禁用 `pwsh-sandbox`/`tool-pwsh` 并重新启用 `bash-sandbox`/`tool-bash`——两个执行器家族注册同一个 `bash` 服务,配方不完整会在加载时 fail loud)。POSIX 主机永远不会收到它。 @@ -19,4 +19,5 @@ ## 已知限制与延期工作 - **patch 会替换整行 `config`**:profile 覆盖必须重述该行需要保留的每个字段;不存在深度合并层。 +- **Claude SDK 的平台 CLI(命令行界面)仍在 Profile 安装闭包中**:base 组合包依赖 Claude 提供方,其生产路径解析宿主提供的 `claude`;移除 SDK 中未使用的可选载荷,推迟到产品安装闭包后续项处理。 - **Windows 的临时目录授权是按会话的私有子目录**——`workspace-write` 把写入限制在工作区与会话自己的 temp 子目录(`\dsh-`,受限子进程的 TMP/TEMP 被改写);`read-only` 不授予任何写入。见 `@deepseek-ai/dsh-sandbox-windows-acl`。 diff --git a/packages/bundle/base/cordis.patch.yml b/packages/bundle/base/cordis.patch.yml index 400fa766d7..2fb5ff10d3 100644 --- a/packages/bundle/base/cordis.patch.yml +++ b/packages/bundle/base/cordis.patch.yml @@ -14,20 +14,13 @@ - insert: - id: timer - name: '@cordisjs/plugin-timer' + name: '@deepseek-ai/cordis-plugin-timer' - id: hmr - name: '@cordisjs/plugin-hmr' + name: '@deepseek-ai/cordis-plugin-hmr' config: root: ['.'] - # The profile's cordis.patch.yml 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' @@ -294,6 +287,15 @@ config: providerName: fork + # Product providers stay on the host plane because the registry is a + # process singleton. Agent presets decide whether their own model sees the + # matching delegation tools; loading either provider starts no product. + - id: subagent-codex + name: '@deepseek-ai/dsh-subagent-codex' + + - id: subagent-claude-code + name: '@deepseek-ai/dsh-subagent-claude-code' + # Continuable background children are selected per delegation tool. The # separately loaded follow-up tool registers the one global `send_message`. - id: tool-subagent-control diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 4713dfa216..655de66ec0 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-base", "description": "The shared dsh core as a profile bundle: every profile's first patch layer, inserting the base plugin rows over the empty profile root", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bundle/base" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -34,8 +41,8 @@ } }, "dependencies": { - "@cordisjs/plugin-hmr": "workspace:*", - "@cordisjs/plugin-timer": "workspace:*", + "@deepseek-ai/cordis-plugin-hmr": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", @@ -63,7 +70,6 @@ "@deepseek-ai/dsh-plan-mode": "workspace:^", "@deepseek-ai/dsh-pwsh-sandbox": "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-session": "workspace:^", @@ -81,6 +87,8 @@ "@deepseek-ai/dsh-spill-local": "workspace:^", "@deepseek-ai/dsh-spill-policy": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-claude-code": "workspace:^", + "@deepseek-ai/dsh-subagent-codex": "workspace:^", "@deepseek-ai/dsh-subagent-fork": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", @@ -114,11 +122,11 @@ "@deepseek-ai/dsh-workspace-context": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bundle/base/src/invariant.ts b/packages/bundle/base/src/invariant.ts index 65365fb193..a3f9b51de0 100644 --- a/packages/bundle/base/src/invariant.ts +++ b/packages/bundle/base/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-base/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-base' diff --git a/packages/bundle/base/tests/base.spec.ts b/packages/bundle/base/tests/base.spec.ts index 2da84a0931..ba93c37a3c 100644 --- a/packages/bundle/base/tests/base.spec.ts +++ b/packages/bundle/base/tests/base.spec.ts @@ -8,14 +8,17 @@ import { fileURLToPath } from 'node:url' import { resolve } from 'node:path' import { describe, expect, it } from 'vitest' import * as yaml from 'js-yaml' -import { entryListSchema } from '@cordisjs/plugin-include' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' describe('dsh-base bundle', () => { it('declares a parseable patch list through the dsh.bundle.patch manifest field', () => { const root = fileURLToPath(new URL('..', import.meta.url)) const manifest = JSON.parse( readFileSync(resolve(root, 'package.json'), 'utf8'), - ) as { dsh?: { bundle?: { patch?: string } } } + ) as { + dependencies?: Record + dsh?: { bundle?: { patch?: string } } + } expect(manifest.dsh?.bundle?.patch).toBe('./cordis.patch.yml') const parsed = yaml.load( readFileSync(resolve(root, manifest.dsh!.bundle!.patch!), 'utf8'), @@ -28,6 +31,12 @@ describe('dsh-base bundle', () => { ) expect(rows.length).toBeGreaterThan(50) expect(rows.some(row => row.id === 'agent-loop')).toBe(true) + expect(rows.filter(row => row.id === 'subagent-codex')).toHaveLength(1) + expect(rows.filter(row => row.id === 'subagent-claude-code')).toHaveLength(1) + expect(manifest.dependencies).toMatchObject({ + '@deepseek-ai/dsh-subagent-codex': 'workspace:^', + '@deepseek-ai/dsh-subagent-claude-code': 'workspace:^', + }) }) it('ships the Windows platform layer as the confined pwsh roster over the ACL runner chain', () => { diff --git a/packages/bundle/headless/README.i18n.yaml b/packages/bundle/headless/README.i18n.yaml index 2ded85f25b..4377802ae4 100644 --- a/packages/bundle/headless/README.i18n.yaml +++ b/packages/bundle/headless/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/bundle/headless/README.md -README.md: f8b76b77f2beb22f501a49f0fc4cf5cd72223765 -README.zh.md: aae8ab5bea663b8909de942f72615f5ef9b16c84 +README.md: 31a4894dbb191d2244371ca7272339e96e253053 +README.zh.md: 6e8d28f10071fbab175c4f14f1aaa9618b8f598a diff --git a/packages/bundle/headless/README.md b/packages/bundle/headless/README.md index f8b76b77f2..31a4894dbb 100644 --- a/packages/bundle/headless/README.md +++ b/packages/bundle/headless/README.md @@ -2,9 +2,9 @@ English | [中文](README.zh.md) -The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`). It mounts no Host, HTTP server, Web runtime, or browser plugin. +The dsh one-shot bundle. [`cordis.patch.yml`](cordis.patch.yml) rides directly over [`dsh-base`](../base/README.md): it supplies the coding persona and tool mode, disables HMR, mounts Code Mode's worker as a core execution capability, and inserts this package's `headless-runner` plugin (config `{task}`, resolved from the injected `headlessStartup` provider). It mounts no Host, HTTP server, Web runtime, or browser plugin. -After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The launcher patches the task text in (`dsh run "task"`) and fails loud when the selected profile lacks this row. +After the Loader settles, the runner reads the shared [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md), creates one fresh persisted Agent through `ctx.agents`, submits the task as an ordinary user message, and waits for quiescence. It flushes the Session before folding the owned durable event interval, writes the last non-empty assistant text to stdout, and requests exit through the launcher-provided `ctx.headlessIo` host hook (final `turn/end` completed → 0, otherwise 1). A terminal `error` reason also writes its code and message to stderr; successful runs keep stderr empty. The process opens no listening port. The task text is this app's command line: the ordinary `headless-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), reads the positional argument of `dsh --profile headless "task"`, prints the app's `--help`, and provides `headlessStartup`; the runner injects that service and reads its task from lazy config. A missing or whitespace-only task is rejected before the runner activates. ## Model Experience diff --git a/packages/bundle/headless/README.zh.md b/packages/bundle/headless/README.zh.md index aae8ab5bea..6e8d28f100 100644 --- a/packages/bundle/headless/README.zh.md +++ b/packages/bundle/headless/README.zh.md @@ -2,9 +2,9 @@ [English](README.md) | 中文 -dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 +dsh 一次性任务组合包。[`cordis.patch.yml`](cordis.patch.yml) 直接叠加在 [`dsh-base`](../base/README.md) 之上:提供编码 persona 和工具模式、禁用 HMR(热模块替换)、将 Code Mode 的 worker 作为核心执行能力挂载,并插入本包的 `headless-runner` 插件(配置为 `{task}`,从注入的 `headlessStartup` 提供方解析)。它不挂载任何 Host、HTTP server、Web runtime 或浏览器插件。 -Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。启动器把任务文本 patch 进来(`dsh run "task"`);若所选 profile 缺少该行,则显式报错。 +Loader 结算后,runner 读取共享的 [`ctx.agentDefaultModel`](../../core/agent-default-model/README.md),通过 `ctx.agents` 创建一个全新的持久化 Agent(智能体),将任务作为普通用户消息提交,并等待完全停稳。它对 Session 执行 flush 后再汇总自身持有的持久化事件区间,将最后一条非空 assistant 文本写入 stdout,再经启动器提供的 `ctx.headlessIo` 宿主钩子请求退出(最终 `turn/end` 完成 → 0,否则为 1)。最终 reason 为 `error` 时,还会将持久化的 code 与 message 写入 stderr;成功运行时 stderr 保持为空。进程不会打开监听端口。任务文本就是这个应用的命令行:普通 `headless-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),读取 `dsh --profile headless "task"` 的位置参数、打印应用自己的 `--help`,并提供 `headlessStartup`;runner 注入该服务,再从惰性配置中读取任务。缺失或只有空白的任务会在 runner 激活前被拒绝。 ## 模型体验 diff --git a/packages/bundle/headless/cordis.patch.yml b/packages/bundle/headless/cordis.patch.yml index 8b147714be..8d2e1ff4ab 100644 --- a/packages/bundle/headless/cordis.patch.yml +++ b/packages/bundle/headless/cordis.patch.yml @@ -1,7 +1,8 @@ # The dsh-headless bundle patch: one-shot task mode directly over dsh-base. -# It mounts no Host, HTTP server, Web runtime, or browser plugin. The launcher -# patches the runner's `task`; the direct driver creates an Agent through the -# core registry and prints the final durable assistant message. +# It mounts no Host, HTTP server, Web runtime, or browser plugin. An ordinary +# provider plugin injects `cmdlineArgs`, parses the task positional +# (`dsh --profile headless ""`) and this app's --help, then the direct +# driver creates an Agent through the core registry and prints its durable result. - id: system-prompt config: @@ -22,5 +23,12 @@ - id: code-runtime name: '@deepseek-ai/dsh-code-runtime-worker' + - id: headless-startup + name: '@deepseek-ai/dsh-headless/startup' + + # Reads its task from the ordinary headlessStartup provider. - id: headless-runner name: '@deepseek-ai/dsh-headless' + inject: [headlessStartup] + config: + task: !!js ctx.headlessStartup.task diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index bc461e4c72..af0935d2a4 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-headless", "description": "The dsh one-shot bundle: a direct core Agent/Session runner over dsh-base with no Host, HTTP, or browser layer", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bundle/headless" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -11,6 +18,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./startup": { + "types": "./lib/types/startup.d.ts", + "default": "./lib/startup.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -22,6 +33,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/startup.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -32,24 +44,27 @@ } }, "dependencies": { + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^", + "commander": "^15.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-agent-default-model": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-default-model": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-default-model": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bundle/headless/src/index.ts b/packages/bundle/headless/src/index.ts index 2ea68c441b..d818e4bccd 100644 --- a/packages/bundle/headless/src/index.ts +++ b/packages/bundle/headless/src/index.ts @@ -8,8 +8,8 @@ */ import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { ModelSelectionRef } from '@deepseek-ai/dsh-agent' import type {} from '@deepseek-ai/dsh-agent-default-model' @@ -17,7 +17,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' // Empty type import carries the loader Context merge for the settlement await. -import type {} from '@cordisjs/plugin-loader' +import type {} from '@deepseek-ai/cordis-plugin-loader' /** Stable Cordis plugin name. */ export const name = 'headless-runner' @@ -25,7 +25,7 @@ export const name = 'headless-runner' /** Core services required before the one-shot turn can start. */ export const inject = ['agentDefaultModel', 'agents', 'sessions'] -/** Plugin config: the task, patched in by the launcher. */ +/** Plugin config: the task resolved from this app's injected provider service. */ export interface Config { /** The prompt text for the single run. */ task: string @@ -52,7 +52,7 @@ export interface HeadlessIo { exit(code: number): void } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Process-facing effects provided before the headless tree mounts. */ headlessIo?: HeadlessIo @@ -106,6 +106,10 @@ async function run(ctx: Context, task: string, io: HeadlessIo): Promise { if (agents === undefined || defaultModel === undefined || sessions === undefined) return const selection = defaultModel.currentSelection() + // This bundle composes no preset roster, so the model-facing rows sit in the + // host plane and the agent reads them from the global layer. A deployment + // that DOES configure one has to join it here first + // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). const { agent } = await agents.create({ sessionId: SessionId(`session-${randomUUID()}`), meta: { cwd: process.cwd() }, diff --git a/packages/bundle/headless/src/invariant.ts b/packages/bundle/headless/src/invariant.ts index 91e4925aa1..cd435b5fcc 100644 --- a/packages/bundle/headless/src/invariant.ts +++ b/packages/bundle/headless/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-headless/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-headless' diff --git a/packages/bundle/headless/src/startup.ts b/packages/bundle/headless/src/startup.ts new file mode 100644 index 0000000000..bfb4d44e51 --- /dev/null +++ b/packages/bundle/headless/src/startup.ts @@ -0,0 +1,62 @@ +/** + * The one-shot app's command-line provider: it parses the task positional and + * `--help`, then publishes {@link HEADLESS_STARTUP_SERVICE}. The runner is an + * ordinary consumer whose lazy config waits for that service. + * @module @deepseek-ai/dsh-headless/startup + */ + +import { Command } from 'commander' +import type { Context } from '@deepseek-ai/cordis' +import { parseCmdline } from '@deepseek-ai/dsh-cmdline' + +/** Stable Cordis plugin name. */ +export const name = 'headless-startup' + +/** Services required before the task can be resolved. */ +export const inject = ['cmdlineArgs'] + +/** Service provided by this plugin and injected by the one-shot runner. */ +export const HEADLESS_STARTUP_SERVICE = 'headlessStartup' + +/** What the runner row reads from {@link HEADLESS_STARTUP_SERVICE}. */ +export interface HeadlessStartupValues { + /** The task text this invocation asked for. */ + task: string +} + +/** + * This app's command: the task positional, its description, and its help text. + * @returns a fresh program, so one process can parse more than once (tests). + */ +function headlessCommand(): Command { + return new Command() + .name('dsh --profile headless') + .description('Answer one task, print the final assistant message, and exit.') + .helpOption('-h, --help', 'show this help') + .argument('[task...]', 'the task text; multiple words are joined by spaces') + .addHelpText('after', ` +Examples: + dsh --profile headless "run the tests" answer one task and exit +`) +} + +/** + * Turn the parsed command line into the runner's task. + * @param program - the parsed headless command. + * @returns the runner's service value. + */ +function planHeadlessStartup(program: Command): HeadlessStartupValues { + const task = program.args.join(' ') + if (task.trim() === '') program.error('error: a task is required, for example: dsh --profile headless "run the tests"') + return { task } +} + +/** + * Parse and provide the one-shot task as an ordinary Cordis service. + * @param ctx - plugin context carrying the command line. + * @returns nothing once the task is provided, or when the command requested exit. + */ +export function apply(ctx: Context): void { + const values = parseCmdline(ctx, headlessCommand(), planHeadlessStartup) + if (values !== undefined) ctx.provide(HEADLESS_STARTUP_SERVICE, values) +} diff --git a/packages/bundle/headless/tests/headless.spec.ts b/packages/bundle/headless/tests/headless.spec.ts index bd24a9300a..788e0a5a10 100644 --- a/packages/bundle/headless/tests/headless.spec.ts +++ b/packages/bundle/headless/tests/headless.spec.ts @@ -1,7 +1,7 @@ /** Direct one-shot Agent driving, durable aggregation, flushing, and exit mapping. */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import AgentDefaultModelService from '@deepseek-ai/dsh-agent-default-model' diff --git a/packages/bundle/headless/tests/startup.spec.ts b/packages/bundle/headless/tests/startup.spec.ts new file mode 100644 index 0000000000..07c200202e --- /dev/null +++ b/packages/bundle/headless/tests/startup.spec.ts @@ -0,0 +1,106 @@ +/** + * The one-shot app's ordinary command-line provider over a real Loader tree: + * the task becomes injected runner config, while help and usage errors leave + * the consumer pending. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { afterEach, describe, expect, it } from 'vitest' +import { apply, HEADLESS_STARTUP_SERVICE, type HeadlessStartupValues } from '../src/startup.ts' + +/** What one boot of the fixture tree observed. */ +interface Observed { + exits: number[] + out: string + runnerConfig?: unknown +} + +const disposers: (() => Promise)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** + * Mount the real provider over a runner stand-in. + * @param args - the invocation's inner arguments. + * @returns the resolved service value and observed runner/process effects. + */ +async function bootStartup(args: string[]): Promise<{ task: HeadlessStartupValues | undefined; observed: Observed }> { + const dir = mkdtempSync(join(tmpdir(), 'dsh-headless-startup-')) + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'row.mjs'), 'export function apply(_ctx, config) { globalThis.__headlessStartupObserved.runnerConfig = config }\n') + // Loader imports through Node's resolver, so this fixture delegates to the + // source-plane plugin already imported by the test. + writeFileSync(join(dir, 'startup.mjs'), ` +export const name = 'headless-startup' +export const inject = ['cmdlineArgs'] +export const apply = ctx => globalThis.__headlessStartupApply(ctx) +`) + const rowUrl = pathToFileURL(join(dir, 'row.mjs')).href + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: headless-runner', + ` name: ${rowUrl}`, + ` inject: [${HEADLESS_STARTUP_SERVICE}]`, + ' config:', + ' task: !!js ctx.headlessStartup.task', + '- id: headless-startup', + ` name: ${pathToFileURL(join(dir, 'startup.mjs')).href}`, + '', + ].join('\n')) + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { + __headlessStartupApply: typeof apply + __headlessStartupObserved: Observed + } + globals.__headlessStartupApply = apply + globals.__headlessStartupObserved = observed + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return { + task: ctx.get(HEADLESS_STARTUP_SERVICE) as HeadlessStartupValues | undefined, + observed, + } +} + +describe('headless command-line provider', () => { + it('joins the task positional into the runner config', async () => { + const { task, observed } = await bootStartup(['run', 'the', 'tests']) + expect(task).toEqual({ task: 'run the tests' }) + expect(observed.runnerConfig).toEqual({ task: 'run the tests' }) + expect(observed.exits).toEqual([]) + }) + + it.each([{ args: [] }, { args: [' '] }])('rejects an invocation with no non-whitespace task ($args)', async ({ args }) => { + const { task, observed } = await bootStartup(args) + expect(observed.out).toContain('a task is required') + expect(task).toBeUndefined() + expect(observed.runnerConfig).toBeUndefined() + expect(observed.exits).toEqual([1]) + }) + + it('prints its own help and leaves the runner pending', async () => { + const { task, observed } = await bootStartup(['--help']) + expect(observed.out).toContain('dsh --profile headless') + expect(task).toBeUndefined() + expect(observed.runnerConfig).toBeUndefined() + expect(observed.exits).toEqual([0]) + }) +}) diff --git a/packages/bundle/headless/tsconfig.json b/packages/bundle/headless/tsconfig.json index 9ae3212f6b..8e0b4ae4b3 100644 --- a/packages/bundle/headless/tsconfig.json +++ b/packages/bundle/headless/tsconfig.json @@ -31,6 +31,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../boot/cmdline" } ] } diff --git a/packages/bundle/web-app/README.i18n.yaml b/packages/bundle/web-app/README.i18n.yaml index 6594cdb6d1..9040315855 100644 --- a/packages/bundle/web-app/README.i18n.yaml +++ b/packages/bundle/web-app/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/bundle/web-app/README.md -README.md: d89ae4a7e28506166498caf0032f864bbb109cc5 -README.zh.md: 746ec2e8b6748a0d72f697d0aea5f3809e7106ee +README.md: b6fa225f5e0a0a079605a4fb9064b79287ab21cd +README.zh.md: 68af959719b9bd146eddd143aa9d98400e65fa68 diff --git a/packages/bundle/web-app/README.md b/packages/bundle/web-app/README.md index d89ae4a7e2..b6fa225f5e 100644 --- a/packages/bundle/web-app/README.md +++ b/packages/bundle/web-app/README.md @@ -2,19 +2,19 @@ English | [中文](README.zh.md) -The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, lanAddresses}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner over it, registers the web-surface prompt section and the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true. The `dsh web` launcher alias patches `mode`/`lanAddresses` and the flag family over these rows. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. +The dsh browser-surface bundle. [`cordis.patch.yml`](cordis.patch.yml) rides over [`dsh-base`](../base/README.md): it sets the coding persona, inserts the Web host rows (webserver, API gateway, workspace, projection cache, storage) and the browser plugin roster, and mounts this package's `web-runtime` glue plugin (config `{mode, printUrl, surfaceContext, trustedHosts}`). That plugin resolves the built frontend dist through `@deepseek-ai/dsh-frontend`'s exports, enables the optional HMR row before client-module discovery so the first development graph contains its reload receiver, samples bind-dependent LAN trust once, provides it as `webRuntime` to the browser-trust fence and client roster, mounts the [`frontend-static`](../../host/frontend-static/README.md) fallback owner, registers the harness-source and web-surface prompt sections plus the bash-visible `DSH_WEB_URL`/`DSH_WEB_MODE` runtime variables when `surfaceContext` is true, and prints the `dsh web:` URL line when `printUrl` is true, after its Loader tree settles so a sibling failure cannot announce a dead app. This bundle also owns the app command line: the ordinary `web-startup` provider ([`src/startup.ts`](src/startup.ts)) injects `ctx.cmdlineArgs` ([`dsh-cmdline`](../../boot/cmdline/README.md)), parses `--host`, `--port`, `--dev`, repeatable `--trusted-host`, and the app's `--help`, then provides `webStartup`. Flag-configured rows inject that service and read it directly from lazy config, so nothing binds a port before argument resolution and `dsh --profile web --help` starts no server. [`dsh-headless`](../headless/README.md) is a sibling surface over the same base and does not mount this bundle. ## Model Experience -### Web-surface prompt section and bash runtime variables +### Harness-source and Web-surface context #### What the model sees -When `surfaceContext` is true, the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither the section nor the variables are registered. +When `surfaceContext` is true, the `harness:source` section identifies the on-disk Harness implementation without claiming it is the working directory, and the `app:web-surface` global section (order −98) orients the model to the GUI: the canonical local URL, the "this page" referent, the HMR/rebuild update contract for the active mode, and the instruction not to start replacement servers. `DSH_WEB_URL` and `DSH_WEB_MODE` additionally appear in the managed bash environment with their descriptions, resolved per invocation from the live server. When it is false, neither section nor the variables are registered. #### Token effect -One prompt paragraph per session plus two managed-environment variable lines; constant per process. +One source line and one prompt paragraph per session plus two managed-environment variable lines; constant per process. #### KV Cache effect diff --git a/packages/bundle/web-app/README.zh.md b/packages/bundle/web-app/README.zh.md index 746ec2e8b6..68af959719 100644 --- a/packages/bundle/web-app/README.zh.md +++ b/packages/bundle/web-app/README.zh.md @@ -2,19 +2,19 @@ [English](README.md) | 中文 -dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, lanAddresses}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 web 表层提示词段落和 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时打印 `dsh web:` URL 行。`dsh web` 启动器别名把 `mode`/`lanAddresses` 与相应 flag 家族 patch 到这些行上。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 +dsh 浏览器表层组合包。[`cordis.patch.yml`](cordis.patch.yml) 叠加在 [`dsh-base`](../base/README.md) 之上:设置 coding persona,插入 Web 宿主行(webserver、API 网关、workspace、投影缓存、存储)与浏览器插件名录,并挂载本包的 `web-runtime` 粘合插件(配置为 `{mode, printUrl, surfaceContext, trustedHosts}`)。该插件通过 `@deepseek-ai/dsh-frontend` 的 exports 解析已构建的前端 dist,在客户端模块发现前启用可选的 HMR 行,确保首份开发模式图中包含它的重载接收端,只采样一次依赖 bind 的 LAN 信任信息并将其作为 `webRuntime` 提供给浏览器信任栅栏和客户端名录,挂载 [`frontend-static`](../../host/frontend-static/README.md) 回退席位所有者,在 `surfaceContext` 为 true 时注册 Harness 源码与 Web 表层提示词段落,以及 bash 可见的 `DSH_WEB_URL`/`DSH_WEB_MODE` 运行时变量,并在 `printUrl` 为 true 时等自身的 Loader 配置树结算后再打印 `dsh web:` URL 行,避免兄弟行失败时公告一个已失效的应用。本组合包还持有应用命令行:普通 `web-startup` 提供方([`src/startup.ts`](src/startup.ts))注入 `ctx.cmdlineArgs`([`dsh-cmdline`](../../boot/cmdline/README.md)),解析 `--host`、`--port`、`--dev`、可重复的 `--trusted-host` 以及应用自己的 `--help`,再提供 `webStartup`。由 flag 配置的行会注入该服务,并在惰性配置中直接读取它,因此参数解析完成前不会有任何东西绑定端口,`dsh --profile web --help` 也不会启动服务器。[`dsh-headless`](../headless/README.md) 是同一 base 之上的同级表层,不挂载本组合包。 ## 模型体验 -### Web 表层提示词段落与 bash 运行时变量 +### Harness 源码与 Web 表层上下文 #### 模型看到的内容 -当 `surfaceContext` 为 true 时,全局段落 `app:web-surface`(顺序 −98)向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,该提示词段和这些变量都不会注册。 +当 `surfaceContext` 为 true 时,`harness:source` 段落标明磁盘上的 Harness 实现,但不会声称它就是工作目录;全局段落 `app:web-surface`(顺序 −98)则向模型说明 GUI:规范的本地 URL、「this page」指代什么、当前模式下 HMR(热模块替换)/重建的更新约定,以及不要启动替代服务器的指令。`DSH_WEB_URL` 与 `DSH_WEB_MODE` 还会连同各自描述出现在受管 bash 环境中,每次调用时从运行中的服务器解析。当它为 false 时,这两个段落和这些变量都不会注册。 #### Token 影响 -每个会话一段提示词,外加两行受管环境变量;每个进程内保持恒定。 +每个会话一行源码说明和一段提示词,外加两行受管环境变量;每个进程内保持恒定。 #### KV Cache 影响 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 3e88c4d6ca..1b34c11d1b 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -3,9 +3,13 @@ # the profile's own cordis.patch.yml and any --patch overlays still to come. # # A patch replaces the targeted row's whole `config`, so each row below -# restates every key it owns. The `dsh web` launcher alias turns --host/--port/ -# --dev/--trusted-host into further patches over these rows -# (`--dev` inserts the dsh-client-hmr row). +# restates every key it owns. +# +# The web-startup plugin injects `cmdlineArgs` and provides `webStartup` as an +# ordinary Cordis service. Rows configured from flags inject that service, so +# Loader resolves their expressions only after it exists. The web runtime then +# provides bind-dependent `webRuntime` values to the trust fence and client +# roster. `dsh --profile web --help` provides neither service, so no server binds. # ── surface-specific values the base deliberately omits ───────────────────── @@ -56,6 +60,11 @@ config: backend: json + - id: message-feedback + name: '@deepseek-ai/dsh-message-feedback' + config: + maxNoteBytes: 8192 + - id: workspace name: '@deepseek-ai/dsh-workspace' @@ -76,44 +85,70 @@ - id: api-gateway name: '@deepseek-ai/dsh-host-apiproxy' + # Ordinary provider for the parsed Web flags. Its plugin-level injection + # waits for cmdlineArgs; no launcher metadata or special row kind is needed. + - id: web-startup + name: '@deepseek-ai/dsh-web-app/startup' + # ── layer 2: transport/service ────────────────────────────────────────────── - # Plain route-registration carrier; host and port arrive as `dsh web` - # flag patches over these defaults. The dist is served by the web-runtime - # row below through the fallback seat. + # Plain route-registration carrier; host and port come from the app's + # webStartup provider, with these deployment fallbacks. The dist is served by + # the web-runtime row below through the fallback seat. - id: webserver name: '@deepseek-ai/dsh-host-webserver' + inject: [webStartup] config: - host: 127.0.0.1 - port: 3080 + host: !!js ctx.webStartup.host ?? '127.0.0.1' + port: !!js ctx.webStartup.port ?? 3080 # Web glue owned by this bundle: resolves the built frontend dist (an # assembly fact of dsh-web-app, never user config), mounts the # frontend-static fallback owner, registers the web-surface prompt - # section and bash runtime variables, and prints the URL line. `dsh web` - # patches mode/lanAddresses over these defaults; complete-prompt overlays - # set surfaceContext false to suppress every model- and shell-visible Web - # runtime contribution. + # section and bash runtime variables, and prints the URL line. The webStartup + # provider supplies invocation-only values; after the server binds, this row + # samples LAN trust once and provides `webRuntime`. A complete agent-preset + # persona suppresses the prompt section for that agent while retaining + # these host-owned shell variables. - id: web-runtime name: '@deepseek-ai/dsh-web-app' + inject: [webStartup] config: - mode: production + mode: !!js ctx.webStartup.mode printUrl: true surfaceContext: true + trustedHosts: !!js ctx.webStartup.trustedHosts + + # The client-plugin reload chain: a dev-only row this bundle ships off, + # which the runtime row turns on before client discovery. It is a row rather + # than a child of web-runtime because its node half is a client-side package, + # which a host-side bundle cannot import. + - id: client-hmr + name: '@deepseek-ai/dsh-client-hmr' + inject: [webStartup] + disabled: true # ── browser plugin roster (dsh.client rows; node halves are layer-2 hosts) ── - # Dual-face: node half scans this very tree for dsh.client rows, composes - # window.__DSH_BOOT__, serves /plugins//client.js; browser half is the - # module table the shell kernel constructs before cordis exists (adopted - # as a plugin entry by the kernel, never fetched). + # Dual-face: this waits for the runtime row to decide whether HMR belongs + # in the first graph. The node half then scans this tree, composes + # window.__DSH_BOOT__, and serves /plugins//client.js; the browser half + # is the module table the shell kernel constructs before cordis exists + # (adopted as a plugin entry by the kernel, never fetched). - id: modules name: '@deepseek-ai/dsh-client-modules' + inject: [webRuntime] # Owns both ends of the web transport: node half binds the gateway to the # webserver under /api; browser half is the fetch/SSE client. - id: connection name: '@deepseek-ai/dsh-client-connection' + inject: [webRuntime] + config: + # LAN literals derived from the active bind plus --trusted-host extras. + # A deployment adding authorities keeps this expression and concatenates + # its literals, for example: ['app.internal', ...ctx.webRuntime.trustedHosts]. + trustedHosts: !!js ctx.webRuntime.trustedHosts - id: api-remotes name: '@deepseek-ai/dsh-api-remotes' @@ -172,6 +207,10 @@ - id: ui-subagent name: '@deepseek-ai/dsh-client-ui-subagent' + # Background tasks: the session-header list over the tasksBySession mirror. + - id: ui-task + name: '@deepseek-ai/dsh-client-ui-task' + # Goal surface: GoalBar in the input dock over the goal session projection. - id: ui-goal name: '@deepseek-ai/dsh-client-ui-goal' @@ -270,8 +309,12 @@ - id: plan-mode disabled: true -- id: token-meter - disabled: true +# The token METER stays on the host plane; only the compaction backend that +# reads it moves. It owns the context-meter projection units, and that table is +# process-wide, so preset ownership would make the meter a function of which +# presets happen to be mounted rather than a per-session fact. Same criterion as +# `tasks` and `goals`; the reasoning has one home in +# `.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md`. - id: compact-basic disabled: true diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 4f8b8d4318..95e7428f0a 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-web-app", "description": "The dsh browser-surface bundle: the web patch layer over dsh-base plus the runtime glue plugin (frontend dist serving, web-surface prompt, bash runtime variables, URL line)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/bundle/web-app" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -11,6 +18,10 @@ "types": "./lib/types/index.d.ts", "default": "./lib/index.js" }, + "./startup": { + "types": "./lib/types/startup.d.ts", + "default": "./lib/startup.js" + }, "./invariant": { "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" @@ -22,6 +33,7 @@ "files": [ "lib/index.js", "lib/invariant.js", + "lib/startup.js", "cordis.patch.yml", "lib/types/**/*.d.ts" ], @@ -33,8 +45,8 @@ }, "dependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-client-connection": "workspace:^", - "@deepseek-ai/dsh-client-ui-agent-preset": "workspace:^", "@deepseek-ai/dsh-client-hmr": "workspace:^", "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", @@ -57,10 +69,12 @@ "@deepseek-ai/dsh-client-ui-skill": "workspace:^", "@deepseek-ai/dsh-client-ui-slash": "workspace:^", "@deepseek-ai/dsh-client-ui-subagent": "workspace:^", + "@deepseek-ai/dsh-client-ui-task": "workspace:^", "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-client-ui-tool": "workspace:^", "@deepseek-ai/dsh-client-ui-trajectory": "workspace:^", "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", "@deepseek-ai/dsh-frontend": "workspace:^", "@deepseek-ai/dsh-frontend-static": "workspace:^", @@ -69,23 +83,27 @@ "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-message-feedback": "workspace:^", "@deepseek-ai/dsh-session-projection-cache": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^", + "commander": "^15.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-bash-env": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-bash-env": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bundle/web-app/src/index.ts b/packages/bundle/web-app/src/index.ts index b14635a868..0a8ec7ffbb 100644 --- a/packages/bundle/web-app/src/index.ts +++ b/packages/bundle/web-app/src/index.ts @@ -4,17 +4,21 @@ * manifest field). The plugin owns the browser-surface glue: it resolves * the built frontend dist (workspace knowledge of this bundle, never user * config), mounts the `frontend-static` fallback owner over it, registers the - * web-surface prompt section and the bash-visible web runtime variables, and - * prints the URL line when configured to. Flag-derived values (`mode`, - * `lanAddresses`, `printUrl`) arrive as launcher patches over this row. + * harness-source and web-surface prompt sections, the bash-visible web runtime + * variables, and the URL line. App command-line values arrive through the + * `webStartup` service expressions in the bundle patch. * @module @deepseek-ai/dsh-web-app */ import { createRequire } from 'node:module' -import type { Context } from 'cordis' -import z from 'schemastery' +import { networkInterfaces } from 'node:os' +import { fileURLToPath } from 'node:url' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { addHarnessSourceSection } from '@deepseek-ai/dsh-app-boot' +import { enableRow } from '@deepseek-ai/dsh-cmdline' import * as FrontendStatic from '@deepseek-ai/dsh-frontend-static' -import type {} from '@cordisjs/plugin-loader' +import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' import type {} from '@deepseek-ai/dsh-system-prompt' import type {} from '@deepseek-ai/dsh-bash-env' @@ -22,41 +26,51 @@ import type {} from '@deepseek-ai/dsh-bash-env' /** Stable Cordis plugin name. */ export const name = 'web-app' +/** This dsh installation's root, from either this package's source or built entry. */ +const SOURCE_ROOT = fileURLToPath(new URL('../../../..', import.meta.url)) +const HMR_ROW_ID = 'client-hmr' + +/** Runtime service that releases Web rows after bind-dependent values resolve. */ +const WEB_RUNTIME_SERVICE = 'webRuntime' + /** Services required before the web runtime can mount. */ export const inject = ['httpServer'] /** Web runtime mode: production, or development when the client-plugin HMR receiver is active. */ export type WebMode = 'production' | 'development' -/** Plugin config: the surface facts the launcher patches over this bundle's defaults. */ +/** Plugin config: composed deployment settings plus per-invocation command-line values. */ export interface Config { /** Whether this process mounted the client-plugin HMR receiver (`dsh web --dev`). */ mode: WebMode - /** Print the URL line on activation; a headless layer over this bundle turns it off. */ + /** Print the URL line on activation; a non-interactive layer can turn it off. */ printUrl: boolean /** * Register the model-visible surface context (the `app:web-surface` prompt * section and the `DSH_WEB_URL`/`DSH_WEB_MODE` bash variables). A one-shot - * layer turns it off: its user is not interacting through the GUI, so the + * non-interactive layer can turn it off when its user is not in the GUI, so the * orientation text would be false. */ surfaceContext: boolean - /** - * LAN IPv4 addresses sampled once by the launcher when the effective bind - * is all-interfaces — the exact snapshot the /api trust fence was - * configured with, so the printed LAN URL can never name an address the - * fence rejects. Empty on a loopback bind. - */ - lanAddresses: string[] + /** Explicit `--trusted-host` authorities from this invocation. */ + trustedHosts: string[] } export const Config: z = z.object({ mode: z.union([z.const('production'), z.const('development')]).default('production'), printUrl: z.boolean().default(true), surfaceContext: z.boolean().default(true), - lanAddresses: z.array(String).default([]), + trustedHosts: z.array(String).default([]), }) +/** Bind-dependent Web values shared by the trust fence and URL display. */ +export interface WebRuntimeValues { + /** LAN IPv4 literals sampled once when the server binds all interfaces. */ + lanAddresses: string[] + /** LAN literals followed by explicit invocation authorities. */ + trustedHosts: string[] +} + /** Environment variable naming the canonical local URL of this Web GUI. */ const DSH_WEB_URL = 'DSH_WEB_URL' as const /** Environment variable naming the Web runtime mode. */ @@ -65,6 +79,27 @@ const DSH_WEB_MODE = 'DSH_WEB_MODE' as const // Display-only mirror of the webserver schema's loopback host: the address the // local URL always prints. Not a source of truth — the schema is. const LOOPBACK_HOST = '127.0.0.1' +/** The webserver schema's all-interfaces bind literal. */ +const ALL_INTERFACES_HOST = '0.0.0.0' + +/** + * Resolve one LAN-trust snapshot from the active server bind. + * + * Derived entries are port-less IP literals: DNS rebinding needs an + * attacker-controlled name, while an IP-literal Host is safe on any port and + * an OS-assigned port is unknowable before bind. + * @param bindHost - the active webserver bind host. + * @param extra - explicit `--trusted-host` values, in argument order. + * @returns the LAN display addresses and invocation-derived fence authorities. + */ +export function resolveLanTrust(bindHost: string, extra: readonly string[]): WebRuntimeValues { + const lanAddresses = bindHost === ALL_INTERFACES_HOST + ? Object.values(networkInterfaces()).flat() + .filter((iface): iface is NonNullable => iface !== undefined && iface.family === 'IPv4' && !iface.internal) + .map(iface => iface.address) + : [] + return { lanAddresses, trustedHosts: [...lanAddresses, ...extra] } +} /** Model-visible orientation and acceptance boundary for sessions created through `dsh web`. */ function webSurfacePrompt(webUrl: string, mode: WebMode): string { @@ -109,11 +144,21 @@ export const internals: { resolveDistIndex: () => string } = { resolveDistIndex * variables, and the URL line. * @param ctx - plugin context carrying the httpServer service. * @param config - validated {@link Config}. + * @returns nothing once the invocation's client roster and runtime contributions are registered. */ -export function apply(ctx: Context, config: Config): void { +export async function apply(ctx: Context, config: Config): Promise { + // Client discovery must start after the optional HMR row has a pending + // fiber. Otherwise its first browser graph omits the reload receiver, which + // cannot use that receiver to discover itself later. + if (config.mode === 'development') await enableRow(ctx, HMR_ROW_ID) + const runtime = resolveLanTrust(ctx.httpServer.host, config.trustedHosts) + // Release dependent rows only after the optional row has a pending fiber and + // bind-dependent trust has been sampled once. + ctx.provide(WEB_RUNTIME_SERVICE, runtime) ctx.plugin(FrontendStatic, { distIndex: internals.resolveDistIndex() }) if (config.surfaceContext) { ctx.inject(['systemPrompt'], (promptCtx) => { + addHarnessSourceSection(promptCtx, SOURCE_ROOT) promptCtx.systemPrompt.section({ name: 'app:web-surface', order: -98, @@ -137,21 +182,24 @@ export function apply(ctx: Context, config: Config): void { // sibling rows (the /api route owner) are still mounting. Await Loader // settlement first; a hand-built tree without a Loader prints at once. const printUrl = (): void => { - // The launcher's boot-time LAN snapshot, not a fresh sample: the printed - // LAN URL must name an address the /api trust fence was configured with. - const lanCandidate = config.lanAddresses[0] + // Reuse the exact LAN snapshot provided to the /api trust fence. + const lanCandidate = runtime.lanAddresses[0] const port = ctx.httpServer.port console.log(`dsh web: ${localWebUrl(ctx)}${lanCandidate === undefined ? '' : ` (LAN: http://${lanCandidate}:${String(port)})`}`) } - const loader = ctx.get('loader') - if (loader === undefined) printUrl() + // This row's own activation can precede a sibling failure. The app owns + // readiness by waiting for its Loader tree, or prints at once in a + // hand-built context without Loader. + const settled = ctx.get('loader')?.await() + if (settled === undefined) printUrl() else { - void loader.await().then(() => { - // The tree can be disposed while settlement was in flight (early + void settled.then(() => { + // The tree can be disposed while the boot was in flight (early // SIGTERM); a URL line for a dead server would only mislead, and // reading the torn-down port would turn a clean shutdown into a crash. if (ctx.get('httpServer') !== undefined) printUrl() - }) + // Loader reports a failed boot; this row only stays quiet. + }, () => {}) } } } diff --git a/packages/bundle/web-app/src/invariant.ts b/packages/bundle/web-app/src/invariant.ts index a91d7cf7d1..13df7f956c 100644 --- a/packages/bundle/web-app/src/invariant.ts +++ b/packages/bundle/web-app/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-web-app/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-app' diff --git a/packages/bundle/web-app/src/startup.ts b/packages/bundle/web-app/src/startup.ts new file mode 100644 index 0000000000..040fe843c0 --- /dev/null +++ b/packages/bundle/web-app/src/startup.ts @@ -0,0 +1,90 @@ +/** + * The web app's command-line provider: it parses the `dsh --profile web` flag + * family (`--host`, `--port`, `--dev`, `--trusted-host`) and its `--help` + * text, then provides the immutable values as {@link WEB_STARTUP_SERVICE}. + * Ordinary rows inject that service before reading it from lazy config. + * @module @deepseek-ai/dsh-web-app/startup + */ + +import { Command } from 'commander' +import type { Context } from '@deepseek-ai/cordis' +import { parseCmdline } from '@deepseek-ai/dsh-cmdline' + +/** Stable Cordis plugin name. */ +export const name = 'web-startup' + +/** Services required before the flags can be resolved. */ +export const inject = ['cmdlineArgs'] + +/** Service provided by this ordinary plugin and injected by flag-configured rows. */ +export const WEB_STARTUP_SERVICE = 'webStartup' + +/** What the web rows read from {@link WEB_STARTUP_SERVICE}. */ +export interface WebStartupValues { + /** `--host`, absent when the invocation did not name one. */ + host?: string + /** `--port`, absent when the invocation did not name one. */ + port?: number + /** Web runtime mode; `--dev` selects development, which also mounts the client-plugin reload chain. */ + mode: 'production' | 'development' + /** Explicit `--trusted-host` authorities, in argument order. */ + trustedHosts: string[] +} + +/** The web flag family, as commander parsed it. */ +interface WebOptions { + host?: string + port?: string + dev?: boolean + trustedHost?: string[] +} + +/** + * This app's command: its flags, its description, and its help text. + * @returns a fresh program, so one process can parse more than once (tests). + */ +function webCommand(): Command { + return new Command() + .name('dsh --profile web') + .description('Serve the DeepSeek Harness browser UI.') + .helpOption('-h, --help', 'show this help') + .option('--host ', 'bind host; pass 0.0.0.0 to reach it from another machine') + .option('--port ', 'listen port; pass 0 to let the OS pick a free one') + .option('--dev', 'mount the client-plugin HMR receiver (run pnpm run dev:web separately to rebuild bundles)') + .option('--trusted-host ', 'extra authority the /api browser-trust fence accepts (host or host:port; repeatable)') + .addHelpText('after', ` +Examples: + dsh --profile web serve on the composed host and port + dsh --profile web --port 8080 serve on another port + dsh --profile web --host 0.0.0.0 reach it from another machine on the LAN + dsh --profile web --dev mount the client-plugin HMR receiver +`) +} + +/** + * Turn the parsed flags into the value injected rows read. + * @param program - the parsed web command. + * @returns this invocation's immutable Web options. + */ +function planWebStartup(program: Command): WebStartupValues { + const options = program.opts() + if (options.port !== undefined && !/^\d+$/.test(options.port)) { + program.error(`error: --port must be a number, got ${JSON.stringify(options.port)}`) + } + return { + ...options.host !== undefined && { host: options.host }, + ...options.port !== undefined && { port: Number(options.port) }, + mode: options.dev === true ? 'development' : 'production', + trustedHosts: options.trustedHost ?? [], + } +} + +/** + * Parse and provide the Web invocation as an ordinary Cordis service. + * @param ctx - plugin context carrying the command line. + * @returns nothing once values are provided, or when the command requested exit. + */ +export function apply(ctx: Context): void { + const values = parseCmdline(ctx, webCommand(), planWebStartup) + if (values !== undefined) ctx.provide(WEB_STARTUP_SERVICE, values) +} diff --git a/packages/bundle/web-app/tests/startup.spec.ts b/packages/bundle/web-app/tests/startup.spec.ts new file mode 100644 index 0000000000..5108d04232 --- /dev/null +++ b/packages/bundle/web-app/tests/startup.spec.ts @@ -0,0 +1,135 @@ +/** + * The Web command-line provider over a real Loader tree: its ordinary service + * releases a consumer whose config reads `ctx.webStartup` directly. + */ + +import { mkdtempSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import { internals, provideCmdline } from '@deepseek-ai/dsh-cmdline' +import { afterEach, describe, expect, it } from 'vitest' +import { apply, WEB_STARTUP_SERVICE, type WebStartupValues } from '../src/startup.ts' + +/** What one fixture boot observed. */ +interface Observed { + exits: number[] + out: string + readerConfig?: unknown +} + +const disposers: (() => Promise)[] = [] + +afterEach(async () => { + for (const dispose of disposers.splice(0)) await dispose() + internals.stdout = process.stdout + internals.stderr = process.stderr +}) + +/** + * Mount the real provider and a consumer using injection-ordered config. + * @param args - the invocation's inner arguments. + * @returns the service value and observed consumer/process effects. + */ +async function bootProvider(args: string[]): Promise<{ + values: WebStartupValues | undefined + observed: Observed +}> { + const dir = mkdtempSync(join(tmpdir(), 'dsh-web-startup-')) + const observed: Observed = { exits: [], out: '' } + writeFileSync(join(dir, 'reader.mjs'), ` +export function apply(_ctx, config) { globalThis.__webStartupObserved.readerConfig = config } +`) + // Node imports the fixture row outside Vite's source resolver, so delegate + // to the source-plane plugin already imported by this test. + writeFileSync(join(dir, 'provider.mjs'), ` +export const name = 'web-startup' +export const inject = ['cmdlineArgs'] +export const apply = ctx => globalThis.__webStartupApply(ctx) +`) + writeFileSync(join(dir, 'cordis.yml'), [ + '- id: reader', + ` name: ${pathToFileURL(join(dir, 'reader.mjs')).href}`, + ` inject: [${WEB_STARTUP_SERVICE}]`, + ' config:', + " host: !!js ctx.webStartup.host ?? '127.0.0.1'", + ' port: !!js ctx.webStartup.port ?? 3080', + ' mode: !!js ctx.webStartup.mode', + ' trustedHosts: !!js ctx.webStartup.trustedHosts', + '- id: provider', + ` name: ${pathToFileURL(join(dir, 'provider.mjs')).href}`, + '', + ].join('\n')) + const observing = { write: (chunk: string) => { observed.out += chunk; return true } } + internals.stdout = observing + internals.stderr = observing + const globals = globalThis as unknown as { + __webStartupApply: typeof apply + __webStartupObserved: Observed + } + globals.__webStartupApply = apply + globals.__webStartupObserved = observed + + const ctx = new Context() + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + provideCmdline(ctx, { args, exit: code => void observed.exits.push(code) }) + await ctx.loader.create({ name: 'cordis:include', config: { path: pathToFileURL(join(dir, 'cordis.yml')).href } }) + await ctx.loader.await() + disposers.push(async () => { await ctx.fiber.dispose() }) + return { + values: ctx.get(WEB_STARTUP_SERVICE) as WebStartupValues | undefined, + observed, + } +} + +describe('web command-line provider', () => { + it('publishes each flag and releases direct service expressions', async () => { + const { values, observed } = await bootProvider([ + '--host', '0.0.0.0', + '--port', '8080', + '--dev', + '--trusted-host', 'lab.internal', 'lab-2.internal', + '--trusted-host', '10.0.0.9', + ]) + expect(values).toEqual({ + host: '0.0.0.0', + port: 8080, + mode: 'development', + trustedHosts: ['lab.internal', 'lab-2.internal', '10.0.0.9'], + }) + expect(observed.readerConfig).toEqual(values) + expect(observed.exits).toEqual([]) + }) + + it('leaves deployment values to each consumer when flags omit them', async () => { + const { values, observed } = await bootProvider([]) + expect(values).toEqual({ mode: 'production', trustedHosts: [] }) + expect(observed.readerConfig).toEqual({ + host: '127.0.0.1', + port: 3080, + mode: 'production', + trustedHosts: [], + }) + }) + + it('prints its own help and leaves the consumer pending', async () => { + const { values, observed } = await bootProvider(['--help']) + expect(observed.out).toContain('dsh --profile web') + expect(observed.out).toContain('--trusted-host') + expect(values).toBeUndefined() + expect(observed.readerConfig).toBeUndefined() + expect(observed.exits).toEqual([0]) + }) + + it('rejects a non-numeric port before the consumer activates', async () => { + const { values, observed } = await bootProvider(['--port', 'abc']) + expect(observed.out).toContain('--port must be a number') + expect(values).toBeUndefined() + expect(observed.readerConfig).toBeUndefined() + expect(observed.exits).toEqual([1]) + }) +}) diff --git a/apps/cli/tests/trusted-hosts.spec.ts b/packages/bundle/web-app/tests/trusted-hosts.spec.ts similarity index 58% rename from apps/cli/tests/trusted-hosts.spec.ts rename to packages/bundle/web-app/tests/trusted-hosts.spec.ts index 5647d01536..5972569b0d 100644 --- a/apps/cli/tests/trusted-hosts.spec.ts +++ b/packages/bundle/web-app/tests/trusted-hosts.spec.ts @@ -1,7 +1,7 @@ /** Single-sample LAN-trust resolution for the /api browser-trust fence (`resolveLanTrust`). */ import { describe, expect, it, vi } from 'vitest' -import { resolveLanTrust, webSurfaceContextEnabled } from '../src/web.ts' +import { resolveLanTrust } from '../src/index.ts' vi.mock('node:os', () => ({ networkInterfaces: () => ({ @@ -26,20 +26,9 @@ describe('resolveLanTrust', () => { expect(trustedHosts).toEqual(['192.168.1.5', '10.0.0.7', 'harness.internal:3080']) }) - it('derives nothing for a loopback or unresolved bind — extras alone stand, no LAN URL to print', () => { + it('derives nothing for a loopback bind — extras alone stand, no LAN URL to print', () => { expect(resolveLanTrust('127.0.0.1', [])).toEqual({ lanAddresses: [], trustedHosts: [] }) - expect(resolveLanTrust(undefined, ['lab.internal'])).toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) - }) -}) - -describe('webSurfaceContextEnabled', () => { - it('defaults to enabled and honors an explicit complete-prompt disable', () => { - expect(webSurfaceContextEnabled(new Map())).toBe(true) - expect(webSurfaceContextEnabled(new Map([ - ['web-runtime', { config: { mode: 'production' } }], - ]))).toBe(true) - expect(webSurfaceContextEnabled(new Map([ - ['web-runtime', { config: { surfaceContext: false } }], - ]))).toBe(false) + expect(resolveLanTrust('127.0.0.1', ['lab.internal'])) + .toEqual({ lanAddresses: [], trustedHosts: ['lab.internal'] }) }) }) diff --git a/packages/bundle/web-app/tests/web-app.spec.ts b/packages/bundle/web-app/tests/web-app.spec.ts index c73f9817db..f8c5079f17 100644 --- a/packages/bundle/web-app/tests/web-app.spec.ts +++ b/packages/bundle/web-app/tests/web-app.spec.ts @@ -2,18 +2,26 @@ * Web runtime glue behavior: dist resolution through the bundle's own hook, * the frontend-static child claiming the fallback seat, the web-surface * prompt section and bash runtime variables, and URL-line printing with the - * launcher's LAN snapshot. + * runtime's bind-dependent LAN snapshot. */ import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { HttpServerService } from '@deepseek-ai/dsh-host-webserver' import { apply, Config, internals } from '../src/index.ts' +vi.mock('node:os', async importOriginal => ({ + ...await importOriginal(), + networkInterfaces: () => ({ + lo0: [{ family: 'IPv4', internal: true, address: '127.0.0.1' }], + en0: [{ family: 'IPv4', internal: false, address: '192.168.1.5' }], + }), +})) + let dist: string | undefined afterEach(() => { @@ -36,9 +44,10 @@ function stageDist(): string { } /** A fake httpServer capturing the fallback seat and index taps. */ -function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } { +function fakeHttpServer(host: '127.0.0.1' | '0.0.0.0' = '127.0.0.1'): { server: HttpServerService; seat: () => unknown } { let fallback: unknown const server = { + host, port: 4567, registerFallback: (handler: unknown) => { fallback = handler @@ -49,6 +58,19 @@ function fakeHttpServer(): { server: HttpServerService; seat: () => unknown } { return { server, seat: () => fallback } } +/** Install the optional HMR row the runtime sequences before client discovery. */ +function provideHmrRow(ctx: Context, settle: () => Promise = async () => {}): string[] { + const updates: string[] = [] + ctx.provide('loader', { + entries: () => [{ + options: { id: 'client-hmr' }, + enableRuntime: async () => { updates.push('client-hmr') }, + }], + await: settle, + } as never) + return updates +} + interface BashContribution { name: string variables: Record @@ -59,7 +81,7 @@ describe('web-app runtime glue', () => { it('mounts dist serving, prompt section, bash variables, and prints the URL with the LAN snapshot', async () => { stageDist() const ctx = new Context() - const { server, seat } = fakeHttpServer() + const { server, seat } = fakeHttpServer('0.0.0.0') ctx.provide('httpServer', server) const contributions: BashContribution[] = [] ctx.provide('bashEnv', { @@ -68,15 +90,22 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) + const enabledRows = provideHmrRow(ctx) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, lanAddresses: ['192.168.1.5'] })) + await apply(ctx, new Config({ mode: 'development', printUrl: true, surfaceContext: true, trustedHosts: ['lab.internal'] })) await ctx.plugin(SystemPrompt, { persona: '' }) // Settle the injected registrations. await new Promise(resolve => setTimeout(resolve, 0)) expect(seat()).toBeDefined() // frontend-static claimed the fallback + expect(enabledRows).toEqual(['client-hmr']) + expect(ctx.get('webRuntime')).toEqual({ + lanAddresses: ['192.168.1.5'], + trustedHosts: ['192.168.1.5', 'lab.internal'], + }) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567 (LAN: http://192.168.1.5:4567)') const assembly = await ctx.systemPrompt.assemble() + expect(assembly.sections.find(entry => entry.name === 'harness:source')?.text).toContain('DeepSeek Harness implementation checkout') const section = assembly.sections.find(entry => entry.name === 'app:web-surface') expect(section?.text).toContain('http://127.0.0.1:4567') expect(section?.text).toContain('--dev') @@ -90,7 +119,7 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() @@ -111,11 +140,12 @@ describe('web-app runtime glue', () => { return () => {} }, } as never) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: false, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) const assembly = await ctx.systemPrompt.assemble() expect(assembly.sections.some(entry => entry.name === 'app:web-surface')).toBe(false) + expect(assembly.sections.some(entry => entry.name === 'harness:source')).toBe(false) expect(contributions).toEqual([]) await ctx.fiber.dispose() }) @@ -125,13 +155,13 @@ describe('web-app runtime glue', () => { const ctx = new Context() ctx.provide('httpServer', fakeHttpServer().server) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await ctx.fiber.dispose() }) - it('defers the URL line until Loader settlement and drops it when the server is gone', async () => { + it('defers the URL line until Loader settlement and drops it on failure or teardown', async () => { stageDist() // Settlement path: the line waits for loader.await() so supervisors can // RPC immediately after observing it. @@ -139,9 +169,9 @@ describe('web-app runtime glue', () => { settled.provide('httpServer', fakeHttpServer().server) let release: () => void const settlement = new Promise((resolve) => { release = resolve }) - settled.provide('loader', { await: () => settlement } as never) + provideHmrRow(settled, () => settlement) const log = vi.spyOn(console, 'log').mockImplementation(() => {}) - apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + await apply(settled, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await new Promise(resolve => setTimeout(resolve, 0)) expect(log).not.toHaveBeenCalled() release!() @@ -149,6 +179,17 @@ describe('web-app runtime glue', () => { expect(log).toHaveBeenCalledWith('dsh web: http://127.0.0.1:4567') await settled.fiber.dispose() + // Failed path: Loader reports the sibling failure; the app prints no URL + // for a process that is about to exit. + log.mockClear() + const failed = new Context() + failed.provide('httpServer', fakeHttpServer().server) + provideHmrRow(failed, async () => { throw new Error('boot failed') }) + await apply(failed, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) + await new Promise(resolve => setTimeout(resolve, 0)) + expect(log).not.toHaveBeenCalled() + await failed.fiber.dispose() + // Torn-down path: settlement resolves after the webserver is gone — no // line, no crash. log.mockClear() @@ -159,8 +200,8 @@ describe('web-app runtime glue', () => { await child let releaseTorn: () => void const tornSettlement = new Promise((resolve) => { releaseTorn = resolve }) - torn.provide('loader', { await: () => tornSettlement } as never) - apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, lanAddresses: [] })) + provideHmrRow(torn, () => tornSettlement) + await apply(torn, new Config({ mode: 'production', printUrl: true, surfaceContext: true, trustedHosts: [] })) await child.dispose() // the httpServer service goes away releaseTorn!() await new Promise(resolve => setTimeout(resolve, 0)) @@ -176,7 +217,7 @@ describe('web-app runtime glue', () => { const { server } = fakeHttpServer() Object.defineProperty(server, 'port', { get: () => undefined }) ctx.provide('httpServer', server) - apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, lanAddresses: [] })) + await apply(ctx, new Config({ mode: 'production', printUrl: false, surfaceContext: true, trustedHosts: [] })) await ctx.plugin(SystemPrompt, { persona: '' }) await new Promise(resolve => setTimeout(resolve, 0)) await expect(ctx.systemPrompt.assemble()).rejects.toThrow('httpServer service missing') diff --git a/packages/bundle/web-app/tsconfig.json b/packages/bundle/web-app/tsconfig.json index 6aadb534cb..195aa985e8 100644 --- a/packages/bundle/web-app/tsconfig.json +++ b/packages/bundle/web-app/tsconfig.json @@ -14,6 +14,15 @@ { "path": "../../../vendor/schemastery" }, + { + "path": "../../../vendor/loader" + }, + { + "path": "../../boot/app-boot" + }, + { + "path": "../../boot/cmdline" + }, { "path": "../../host/frontend-static" }, diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index 816f8737e7..c1f21425d6 100644 --- a/packages/client/README.i18n.yaml +++ b/packages/client/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/client/README.md -README.md: 567e10f74ae9d017abef1d876401a958eb80fcfd -README.zh.md: ad6a9fb199c4118b864b80a466ddef40676b7169 +README.md: bbc32fb3944dcb3b7aa48ef1f8e24e5c93ff7a67 +README.zh.md: 5bfbd1ce6b41a44d3ef421ea59ecc29e1c329b3c diff --git a/packages/client/README.md b/packages/client/README.md index 567e10f74a..bbc32fb394 100644 --- a/packages/client/README.md +++ b/packages/client/README.md @@ -29,6 +29,7 @@ The browser side of the dsh web GUI: shell boot, browser-host communication, sha | [`ui-slash/`](ui-slash/README.md) | Coordinates inline command and reference suggestions. | | [`ui-skill/`](ui-skill/README.md) | Adds skill references to inline suggestions. | | [`ui-subagent/`](ui-subagent/README.md) | Provides subagent navigation, child transcript states, and inline references. | +| [`ui-task/`](ui-task/README.md) | Lists this session's background tasks in the conversation header. | | [`ui-model/`](ui-model/README.md) | Provides model selection in conversation surfaces. | | [`ui-permission/`](ui-permission/README.md) | Configures default permissions and switches the current session's access. | | [`ui-plan/`](ui-plan/README.md) | Presents active plan-mode status and its exit control. | diff --git a/packages/client/README.zh.md b/packages/client/README.zh.md index ad6a9fb199..5bfbd1ce6b 100644 --- a/packages/client/README.zh.md +++ b/packages/client/README.zh.md @@ -29,6 +29,7 @@ dsh web GUI 的浏览器侧:shell 启动、浏览器与宿主通信、共享 U | [`ui-slash/`](ui-slash/README.md) | 协调内联命令和引用建议。 | | [`ui-skill/`](ui-skill/README.md) | 向内联建议添加 skill(技能)引用。 | | [`ui-subagent/`](ui-subagent/README.md) | 提供 subagent 导航、子会话记录状态和内联引用。 | +| [`ui-task/`](ui-task/README.md) | 在会话标题栏列出当前会话的后台任务。 | | [`ui-model/`](ui-model/README.md) | 在会话界面中提供模型选择。 | | [`ui-permission/`](ui-permission/README.md) | 配置默认权限并切换当前会话的访问模式。 | | [`ui-plan/`](ui-plan/README.md) | 展示生效中的 plan mode 状态及其退出控件。 | diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 1433c59655..025b5ffb90 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-connection", "description": "Wire consumer layer: HTTP-up/WebSocket-down client, ConnectionController dual streams with reconnect, and fixture api", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/connection" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -37,7 +44,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "ws": "^8.21.0" }, "files": [ @@ -47,14 +54,14 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-host-webserver": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/ws": "^8.18.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/connection/src/client/api.ts b/packages/client/connection/src/client/api.ts index a92db49899..980a2f02a8 100644 --- a/packages/client/connection/src/client/api.ts +++ b/packages/client/connection/src/client/api.ts @@ -17,6 +17,7 @@ export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView, CredentialsApi, CredentialView, ConfigurableProviderView, DiscoveredModelView, LlmApi, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, + TaskView, } from '@deepseek-ai/dsh-host-apiproxy/api' export type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' export type { diff --git a/packages/client/connection/src/client/fixture.ts b/packages/client/connection/src/client/fixture.ts index f149e28984..08837db9c5 100644 --- a/packages/client/connection/src/client/fixture.ts +++ b/packages/client/connection/src/client/fixture.ts @@ -2834,6 +2834,12 @@ function createFixtureWorld(options: FixtureOptions): FixtureWorld { }) return Promise.resolve({ accepted: true }) }, + // Satisfies the ApiProxy contract type only: the browser export button + // fetches GET /api/session.export directly (window.fetch), so this stub is + // never reached through the fixture's dispatch. + downloads: { + sessionLog: () => Promise.resolve(new Response('fixture mode does not serve session export', { status: 404 })), + }, } const rpc: ClientConnectionRpc = { diff --git a/packages/client/connection/src/client/index.ts b/packages/client/connection/src/client/index.ts index 0788e4508e..1a89d0649c 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -3,7 +3,7 @@ * the shared API client, and lets the runtime object layer start the stream * controller with its sinks. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { IApiClient } from './api.ts' import { ConnectionController, type ConnectionConfig, type ConnectionSinks, type ConnectionState } from './connection.ts' import { FixtureApiClient } from './fixture.ts' @@ -22,6 +22,7 @@ export type { ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelSelection, QueueAction, QueuedInboxItem, SessionModels, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, + TaskView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, HostDescription, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, diff --git a/packages/client/connection/src/index.ts b/packages/client/connection/src/index.ts index b2189501cb..11d9a2fd87 100644 --- a/packages/client/connection/src/index.ts +++ b/packages/client/connection/src/index.ts @@ -1,6 +1,6 @@ /** Host HTTP bridge for browser-client RPC. */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-attachment' // Activates the httpServer Context merge used below. import type { WebRoute, WebUpgradeRoute } from '@deepseek-ai/dsh-host-webserver' diff --git a/packages/client/connection/src/invariant.ts b/packages/client/connection/src/invariant.ts index 1112a4e638..78394263cf 100644 --- a/packages/client/connection/src/invariant.ts +++ b/packages/client/connection/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-connection' diff --git a/packages/client/connection/src/rpc-host.ts b/packages/client/connection/src/rpc-host.ts index 7d3e5ff6f5..ec37719768 100644 --- a/packages/client/connection/src/rpc-host.ts +++ b/packages/client/connection/src/rpc-host.ts @@ -1,6 +1,6 @@ /** Host registry and HTTP adapter for generic Connection RPC channels. */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { WebRoute } from '@deepseek-ai/dsh-host-webserver' import { clientRequestSchema, @@ -32,7 +32,7 @@ interface ConnectionRpcInterceptor { readonly options: ConnectionRpcHandlerOptions } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Host Connection transport and RPC registrations. */ connection: HostConnectionHandle diff --git a/packages/client/connection/tests/client-apply.spec.ts b/packages/client/connection/tests/client-apply.spec.ts index 9d9bbd2f26..9c72e862e2 100644 --- a/packages/client/connection/tests/client-apply.spec.ts +++ b/packages/client/connection/tests/client-apply.spec.ts @@ -2,7 +2,7 @@ * Connection plugin browser-half apply: ctx.connection handle mounting, mode * selection off the page URL, and the single-consumer stream-loop ownership. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { apply, type ConnectionHandle } from '../src/client/index.ts' import type { RpcMessage } from '../src/client/api.ts' diff --git a/packages/client/connection/tests/node-half.spec.ts b/packages/client/connection/tests/node-half.spec.ts index fc69e4d7a1..54a2ab7e5c 100644 --- a/packages/client/connection/tests/node-half.spec.ts +++ b/packages/client/connection/tests/node-half.spec.ts @@ -2,7 +2,7 @@ import { EventEmitter, once } from 'node:events' import { createServer, request as httpRequest } from 'node:http' import { PassThrough, Readable } from 'node:stream' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { AddressInfo } from 'node:net' import type { IncomingMessage, ServerResponse } from 'node:http' diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 04e94c24bd..6e7614bc27 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-hmr", "description": "Dev-only hot-reload driver for script-loaded client entries: SSE rebuilt frames → invalidate/prefetch → fiber swap through the vendored Loader entry", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/hmr" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -31,21 +38,21 @@ }, "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-client-modules": "^0.0.1", - "@deepseek-ai/dsh-host-webserver": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-client-modules": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-client-modules": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/hmr/src/client/index.ts b/packages/client/hmr/src/client/index.ts index d0558bcecc..8e440ac0de 100644 --- a/packages/client/hmr/src/client/index.ts +++ b/packages/client/hmr/src/client/index.ts @@ -61,8 +61,8 @@ * fiberless (the next rebuilt frame retries from scratch); an apply failure * leaves a FAILED fiber for the shell's status projection. Both log loudly. */ -import type { Context } from 'cordis' -import type { Entry, Loader } from '@cordisjs/plugin-loader' +import type { Context } from '@deepseek-ai/cordis' +import type { Entry, Loader } from '@deepseek-ai/cordis-plugin-loader' import type { PluginsEventFrame } from '../events.ts' import { EVENTS_ENDPOINT } from '../events.ts' diff --git a/packages/client/hmr/src/index.ts b/packages/client/hmr/src/index.ts index 848b546b13..17604133eb 100644 --- a/packages/client/hmr/src/index.ts +++ b/packages/client/hmr/src/index.ts @@ -8,8 +8,8 @@ */ import { statSync } from 'node:fs' import type { ServerResponse } from 'node:http' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' // Empty type imports carry the clientModuleHost/httpServer Context merges. import type {} from '@deepseek-ai/dsh-client-modules' import type {} from '@deepseek-ai/dsh-host-webserver' diff --git a/packages/client/hmr/src/invariant.ts b/packages/client/hmr/src/invariant.ts index cc875f3e13..1054d91430 100644 --- a/packages/client/hmr/src/invariant.ts +++ b/packages/client/hmr/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-client-hmr/invariant */ -import type { Context, Fiber } from 'cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-hmr' diff --git a/packages/client/hmr/tests/node-half.spec.ts b/packages/client/hmr/tests/node-half.spec.ts index 5224061e61..7f0e1ded83 100644 --- a/packages/client/hmr/tests/node-half.spec.ts +++ b/packages/client/hmr/tests/node-half.spec.ts @@ -5,7 +5,7 @@ import { mkdtempSync, rmSync, statSync, unlinkSync, utimesSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import type { WebBootGraph, ClientModuleHostService } from '@deepseek-ai/dsh-client-modules' import type { WebRoute, HttpServerService } from '@deepseek-ai/dsh-host-webserver' diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index 0814c86b8d..6415c26961 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-locale", "description": "Locale plugin: Host-backed zh/en preference, browser-derived fallback, locale snapshots, and typed namespace dictionaries", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/locale" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -34,12 +41,12 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -48,12 +55,12 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/locale/src/client/index.ts b/packages/client/locale/src/client/index.ts index b8c06872db..3eea9eede6 100644 --- a/packages/client/locale/src/client/index.ts +++ b/packages/client/locale/src/client/index.ts @@ -9,7 +9,7 @@ * ui-slots): in THIS unit the map holds only this package's own merges, but * consumers merge more namespaces in and the intersection keeps them * string-typed. The rule fires on the narrow-map view, not real redundancy. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { type BoundActions, type LocaleDictOf, type LocaleNamespaceMap, type Translate, type TranslateNS, } from '@deepseek-ai/dsh-client-ui-slots' @@ -68,7 +68,7 @@ export interface LocaleSnapshot { revision: number } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { locale: LocaleService } diff --git a/packages/client/locale/src/index.ts b/packages/client/locale/src/index.ts index c8d7ed9f95..af1c9a3057 100644 --- a/packages/client/locale/src/index.ts +++ b/packages/client/locale/src/index.ts @@ -1,6 +1,6 @@ /** Host registration for the browser locale preference. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { LOCALE_SETTINGS_NAMESPACE, LocaleSettingsSchema } from './locale-settings.ts' diff --git a/packages/client/locale/src/invariant.ts b/packages/client/locale/src/invariant.ts index 96c94018f0..6c28c2353e 100644 --- a/packages/client/locale/src/invariant.ts +++ b/packages/client/locale/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-locale' diff --git a/packages/client/locale/src/locale-settings.ts b/packages/client/locale/src/locale-settings.ts index c5d0399f86..9733714502 100644 --- a/packages/client/locale/src/locale-settings.ts +++ b/packages/client/locale/src/locale-settings.ts @@ -1,6 +1,6 @@ /** Locale preference stored in the Host user-settings document. */ -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' /** Settings namespace owned by the locale plugin. */ export const LOCALE_SETTINGS_NAMESPACE = 'locale' diff --git a/packages/client/locale/tests/apply.spec.ts b/packages/client/locale/tests/apply.spec.ts index 84f4299ef2..c82e7c7cb3 100644 --- a/packages/client/locale/tests/apply.spec.ts +++ b/packages/client/locale/tests/apply.spec.ts @@ -1,7 +1,7 @@ /** locale apply wiring: service + dictionaries provision, declaration-aware * Language row registration, snapshot projection into the row store, and * recovery after an HMR collapse of the declaring entry. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { diff --git a/packages/client/locale/tests/host.spec.ts b/packages/client/locale/tests/host.spec.ts index 8fa339e660..809ed5bc9e 100644 --- a/packages/client/locale/tests/host.spec.ts +++ b/packages/client/locale/tests/host.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { diff --git a/packages/client/locale/tests/invariant.spec.ts b/packages/client/locale/tests/invariant.spec.ts index 2b362cb115..c55efa9a12 100644 --- a/packages/client/locale/tests/invariant.spec.ts +++ b/packages/client/locale/tests/invariant.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-locale' import { apply as clientApply, COMMON_NS, LocaleService, inject } from '@deepseek-ai/dsh-client-locale/client' import * as LocaleInvariant from '@deepseek-ai/dsh-client-locale/invariant' diff --git a/packages/client/locale/tests/language-row.spec.tsx b/packages/client/locale/tests/language-row.spec.tsx index 6addc8c69c..e04fa9c311 100644 --- a/packages/client/locale/tests/language-row.spec.tsx +++ b/packages/client/locale/tests/language-row.spec.tsx @@ -16,7 +16,7 @@ const OPTIONS = [{ id: 'zh', label: '中文' }, { id: 'en', label: 'English' }] /** Empty global standard-kit hooks (the row reads neither). */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/locale/tests/locale.spec.ts b/packages/client/locale/tests/locale.spec.ts index 2b696f6fc5..fb751cb301 100644 --- a/packages/client/locale/tests/locale.spec.ts +++ b/packages/client/locale/tests/locale.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import type { LocaleSettings, LocaleSnapshot } from '@deepseek-ai/dsh-client-locale/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index 906ef783ed..3d97f98fa6 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-modules", "description": "Client module system, dual-face: node half composes the __DSH_BOOT__ entry graph (incremental dsh.client scan, bundle route, index tap, webPlugins service); browser half is the lazy-CJS module table the vendored cordis Loader consumes as its internal seam", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/modules" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -35,10 +42,10 @@ }, "license": "BSD-3-Clause", "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", @@ -47,7 +54,7 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/modules/src/client/index.ts b/packages/client/modules/src/client/index.ts index a516d6cef9..c7efe7df9f 100644 --- a/packages/client/modules/src/client/index.ts +++ b/packages/client/modules/src/client/index.ts @@ -9,7 +9,7 @@ * a no-op against the already-registered entry. * @module @deepseek-ai/dsh-client-modules/client */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { DshWindow } from './manifest.ts' export { ClientModuleSystem } from './system.ts' diff --git a/packages/client/modules/src/client/manifest.ts b/packages/client/modules/src/client/manifest.ts index 50b2f8d985..a780525a9c 100644 --- a/packages/client/modules/src/client/manifest.ts +++ b/packages/client/modules/src/client/manifest.ts @@ -30,10 +30,10 @@ * composes the wire. */ -import type {} from 'cordis' +import type {} from '@deepseek-ai/cordis' import type { ClientModuleSystem } from './system.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The client module system the web shell builds at boot (provided by the `./client` wrapper plugin). */ modules: ClientModuleLoader diff --git a/packages/client/modules/src/index.ts b/packages/client/modules/src/index.ts index a0f932fa84..919c139283 100644 --- a/packages/client/modules/src/index.ts +++ b/packages/client/modules/src/index.ts @@ -26,9 +26,9 @@ import { readFile } from 'node:fs/promises' import type { IncomingMessage, ServerResponse } from 'node:http' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' -import { Service } from 'cordis' -import type { Context } from 'cordis' -import type {} from '@cordisjs/plugin-loader' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' +import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' import type { WebBootEntry, WebBootGraph } from './client/manifest.ts' @@ -36,7 +36,7 @@ export type { BootManifest, BootModuleRow, BootPluginRow, WebBootEntry, WebBootGraph, } from './client/manifest.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The web plugin table (provided by the client-modules node half). */ clientModuleHost: ClientModuleHostService diff --git a/packages/client/modules/src/invariant.ts b/packages/client/modules/src/invariant.ts index ad9605f5dd..13b48be9a8 100644 --- a/packages/client/modules/src/invariant.ts +++ b/packages/client/modules/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-modules' diff --git a/packages/client/modules/tests/node-half.spec.ts b/packages/client/modules/tests/node-half.spec.ts index c2c865fd12..7a95281591 100644 --- a/packages/client/modules/tests/node-half.spec.ts +++ b/packages/client/modules/tests/node-half.spec.ts @@ -5,7 +5,7 @@ import type { IncomingMessage, ServerResponse } from 'node:http' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import type { HttpServerService, WebRoute } from '@deepseek-ai/dsh-host-webserver' import { ClientModuleHostService } from '../src/index.ts' diff --git a/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 7dc91083c9..3d3c29e757 100644 --- a/packages/client/runtime/README.i18n.yaml +++ b/packages/client/runtime/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/client/runtime/README.md -README.md: 1ec6cc38aed1bebff6b6ecb40faee7ae3ba9e412 -README.zh.md: 6602152790a1d433371e27b274a4eb8c9e3cfcd8 +README.md: 7c835deb58db149710495f97a2553c3de58d99da +README.zh.md: edf4473bec7df2253c032c3da86da878cdeade09 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 1ec6cc38ae..7c835deb58 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -2,10 +2,9 @@ English | [中文](README.zh.md) -Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects and the Chat-facing list, scope, and event-window state; SessionHistoryService lazily owns independent raw-history ledgers for inspection consumers, loading the current tail first and prepending one older page only when its consumer requests it. Each history snapshot exposes the raw window's absolute base sequence so a consumer detects a prepend even when the page adds no surface-visible node. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into the Session, Workspace, and activated history owners without routing inspection state through Session or SessionManager, and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. +Client cordis boot and React-free object services: SlotsService wraps SlotCore and supplies renderer data sources; SessionsService owns Session objects, list and scope state, and the shared event window and history paging used by registered conversation view targets. WorkspacesService depends on SessionsService and owns Workspace objects, list/actions, default-target derivation, and the New Session blank-reuse entry (`connectWorkspace`). The runtime fans the shared Host stream into Session and Workspace owners and bridges the registry-invalidation frames to typed ctx events (`commands/changed`, `session/preset-changed`, `settings/changed`, `credentials/changed`, `models/changed`) so surface caches refetch without touching the stream. `host/session-preset-changed` also folds its preset into the session row, because the switch's RPC echo reaches only the client that issued it. Client sessions are always Host-born (Session+Agent+cwd in one `session.create`); the client holds no pre-entity session state — a session's Agent scope (the client mirror of host dsh-scope, keyed by the shared agent/session id) is born when its row enters the list mirror and dies with the prune. Contract: api-contracts v3 §4. Each `Session` holds a generic `ProjectionValueStore` seeded from the history-tail `projections` block and updated by `session/projection` frames under higher-seq-wins; domain keys (including `todos`) are read via `projections.faceOf` / `useProjection`, not via `ConversationSnapshot`. The store also publishes one reference-stable whole-value map through `SessionSummary.projectionValues`, allowing global list consumers to reuse the same projections without creating per-session subscriptions. `bindSettingsScope` is the browser mirror of the Host-side settings owner seam for one domain-owned namespace. It subscribes before starting a nonblocking initial read, publishes a uSES snapshot (status, section value, revision, writability, host/memory mode), serializes `set` writes with the latest known namespace revision, suppresses stale publications, recovers a rejected latest write from Host state, and reaches quiescence on plugin disposal. The default decoder validates each section against the namespace's own serialized wire schema (rehydrated through dsh-client-schema-form), so a domain adds a decoder only to narrow beyond that schema. Loopback pages use the Host settings API; remote pages stay in memory mode. Domain packages own the namespace schema, default, and live service rather than putting product policy in runtime. - ## Slot declaration injection `ctx.slots.inject(name, callback)` makes a full `SlotMap` key the dependency for a contribution whose plugin can activate independently from the declaring entry. It runs `callback` synchronously when the declaration exists, otherwise waits; declaration collapse disposes the callback effect, and redeclaration reruns it. The controller belongs to the caller's plugin fiber, so unloading the contributor cancels either the wait or its active registrations. A direct `slots.register()` into an undeclared slot still throws. @@ -26,6 +25,8 @@ SlotsService gives the renderer separate bare observables for `useSessions` and `indexSubagentDescendants()` derives per-parent total and running descendant counts from the retained list mirror. It follows only uninterrupted `origin: 'subagent'` ancestry, so an ordinary fork starts a separate ownership subtree; cycles stop without throwing, and a missing parent remains a harmless key until its summary arrives. +`SessionListState.tasksBySession` mirrors the Host's `session/tasks` frames last-wins, keyed by session and needing no Session instance. An emptied set is stored as an absent key, so absence and `[]` are one representation and consumers never test a sentinel. Two clears keep it from outliving its truth: `session/subscribed` drops the session's mirror, because a fresh generation sends a baseline only for a non-empty set and a retained list would survive as a phantom, and `host/session-removed` drops it again, because owner disposal removed the records on the mux stream while the removal frame rides the host stream, leaving the two with no relative order. + `SessionsService.search(query, signal)` is a stateless one-shot action over the `session.search` RPC. It returns ranked session/snippet pairs without putting query, loading, or error state into the shared Session list, so each UI owner controls debounce, cancellation, stale-response suppression, and fallback presentation. `searchResultLimit` re-exposes `SESSION_SEARCH_RESULT_LIMIT` — the bound the response schema itself enforces — as injected presentation data, so client plugins do not duplicate it. It is a protocol constant rather than per-connection state, so the connection handle does not carry it. ## New Session and the blank mirror @@ -42,17 +43,17 @@ Each `Session` gives its contiguous event window to a `ConversationNodeAssembler Definition authors keep matching local to the current event, give every correlated event a stable business id, and make updates replayable by log `seq`; renderers consume final Node data and constrained Location values rather than scanning Session or Chat collections. The [Conversation Node cookbook](../../../docs/cookbook/adding-a-conversation-node.md) gives the complete registration and pagination path. -`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. Trajectory consumes neither compatibility surface; its activated `session-history` inspection keeps an independent fold until it gains its own registered target. +`ui-conversation` registers the built-in Chat Definitions and the keyed Chat snapshot builder. Append-origin user, assistant, and Tool results remain the human record; model-only replacement copies stay out, except that a compaction checkpoint becomes its own marker and resolves missing summary provenance when an older page supplies it. Durable inbox splice Contexts classify next-step user messages as steering without making inbox state a Session special case. Context messages retain producer provenance and form. StatsLine reads `ConversationSnapshot.chat.legacy.nodes`, while Session mirrors that legacy slice into the top-level `nodes`, `partial`, and `runningCalls` public compatibility fields without running a second business fold. `ui-trajectory` registers independent Definitions and a target builder over the same Session window; it preserves the existing stage-oriented view model without consuming the Chat compatibility fields or running another history fold. The Chat builder keeps one mutable keyed store per Session. Content updates notify only the affected node key, structural changes rebuild order and Location membership, and a prepend adds rows without replacing existing keyed values. Assistant chunks update Definition State for every event but request at most one materialization per animation frame; final messages and Turn/Step closure publish immediately. See the [client Tool presentation decision](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md). -## Request inspection +## Trajectory request data -`SessionHistoryInspection.requests` is one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. +Trajectory Definitions assemble one chronological, purpose-discriminated provider-request stream. Assistant requests always carry their numeric `turn` and `step`; compaction requests carry `step: 0` and a `turn` owner that may be `null`. That null owner means a manual compaction ran standalone between turns, not that it belongs to either adjacent turn. A `session/end-seed` boundary closes an unmatched compaction request as an error at the boundary time with `Compaction was interrupted before completion.`; a later start projects as an independent request instead of overwriting the orphan. ## Code Mode child-call tree -Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. The separate Trajectory history fold still uses Runtime's `ToolCallTree` over the same nested data contract. +Every `ToolCallBlock` recursively owns its children through `subCalls`, in start order. Chat's Tool Definition correlates root calls and results by call id, folds Code Dispatch start/settlement records into that root Context, and projects one keyed recursive tree; child calls never become independent Chat roots. When a start falls outside the loaded window, its settlement remains renderable with `callTime: null`. A child update copies only its ancestor path, so unchanged siblings retain object identity. Edges that introduce a cycle or exceed the fixed 256-call depth limit are consumed without mutating the tree. Trajectory's Tool Definition independently assembles the same nested data contract for its target. ## Session title projection diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6602152790..edf4473bec 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -2,10 +2,9 @@ [English](README.md) | 中文 -客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象以及 Chat 所需的列表、scope 和事件窗口状态;SessionHistoryService 为检查类消费方惰性拥有彼此独立的原始历史账本,先加载当前尾部,并仅在消费方请求时向前补入一页更早历史。每份历史快照都会公开原始窗口的绝对基准序号,因此即使该页没有新增任何 surface 可见节点,消费方仍能检测到向前补页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session、Workspace 和已激活的历史数据所有者,不让检查状态经过 Session 或 SessionManager,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 +客户端 cordis 启动与不依赖 React 的对象服务:SlotsService 包装 SlotCore 并提供 renderer 数据源;SessionsService 拥有 Session 对象、列表与 scope 状态,以及供已注册 conversation view target 共用的事件窗口与历史分页。WorkspacesService 依赖 SessionsService,拥有 Workspace 对象、列表/操作、默认目标派生,以及 New Session 空会话复用入口(`connectWorkspace`)。运行时把共享 Host 流分发给 Session 与 Workspace 所有者,并把注册表失效帧桥接为类型化 ctx 事件(`commands/changed`、`session/preset-changed`、`settings/changed`、`credentials/changed`、`models/changed`),使各表面缓存无需触碰流即可重拉。`host/session-preset-changed` 还会把其中的 preset 折进会话行,因为这次切换的 RPC 回执只会到达发起它的那个客户端。客户端会话一律由 Host 创建(一次 `session.create` 同时产生 Session、agent(智能体)和 cwd);客户端不持有任何实体化之前的会话状态——agent scope(host dsh-scope 的客户端镜像,以 agent/session 共用 id 为键)在会话行进入列表镜像时创建,并随 prune 销毁。约定:api-contracts v3 §4。每个 `Session` 持有一个通用的 `ProjectionValueStore`,由历史记录尾部的 `projections` 块播种,并经 `session/projection` 帧按 seq 高者胜更新;领域键(含 `todos`)经 `projections.faceOf`/`useProjection` 读取,不经 `ConversationSnapshot`。该 store 还会通过 `SessionSummary.projectionValues` 发布一份引用稳定的完整值映射,使全局列表消费方无需为每个会话创建订阅,即可复用同一组投影。 `bindSettingsScope` 面向单个由领域持有的 namespace,是 Host 侧 settings owner seam 的浏览器镜像。它在开始非阻塞初始读取前建立订阅,发布 uSES 快照(状态、分节值、revision、可写性、host/内存模式),使用已知最新 namespace revision 串行执行 `set` 写入,抑制陈旧发布,并在最新写入被拒时从 Host 状态恢复;插件释放时,它会达到完全停稳。默认解码器会对照该 namespace 自身的序列化 wire schema(经 dsh-client-schema-form 还原)校验每个分节,因此领域只有在需要比该 schema 进一步收窄时才添加解码器。回环页面使用 Host settings API,远程页面则停留在内存模式。namespace schema、默认值与实时服务归领域包所有,而非把产品政策放入运行时。 - ## Slot 声明注入 `ctx.slots.inject(name, callback)` 将完整的 `SlotMap` key 作为贡献项的依赖,适用于贡献方插件可独立于声明条目激活的情形。声明存在时,它会同步运行 `callback`,否则等待;声明折叠会 dispose(资源释放)回调 effect,重新声明则会再次运行回调。控制器归调用方的插件 fiber 所有,因此卸载贡献方会取消等待或移除其活跃注册项。直接调用 `slots.register()` 向未声明 slot 注册仍会抛出异常。 @@ -26,6 +25,8 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 `indexSubagentDescendants()` 从保留的列表镜像中派生每个 parent 的后代总数与运行中后代数。它只沿不间断的 `origin: 'subagent'` 祖先链追踪,因此普通 fork 会开启独立的归属子树;遇到环时,追踪会停止但不会抛出异常,缺失的 parent 则会保留为无害的键,直至其摘要到达。 +`SessionListState.tasksBySession` 按 last-wins 镜像宿主的 `session/tasks` 帧,以会话为键,不需要 Session 实例。被清空的集合存为缺失的键,因此「缺失」与 `[]` 是同一种表示,消费方永远不必检测哨兵值。两处清理让它不至于比它所反映的真相活得更久:`session/subscribed` 丢弃该会话的镜像,因为新一代只为非空集合发送 baseline,被留下的列表会变成幽灵;`host/session-removed` 再丢一次,因为 owner 销毁是在 mux 流上移除记录的,而移除帧走 host 流,两者没有相对顺序。 + `SessionsService.search(query, signal)` 是基于 `session.search` RPC 的无状态单次操作。它返回经过排序的会话/snippet 对,但不会将查询条件、加载状态或错误状态写入共享 Session 列表,因此每个 UI 所有者都自行负责防抖、取消、抑制陈旧响应和回退呈现。`searchResultLimit` 将 `SESSION_SEARCH_RESULT_LIMIT`——即响应 schema 自身强制执行的上限——作为注入的呈现数据重新公开,使客户端插件无需复制该值。它是协议常量而非逐连接状态,因此连接 handle 不携带它。 ## New Session 与 blank 镜像 @@ -42,17 +43,17 @@ SlotsService 分别为 renderer 提供 `useSessions` 与 `useWorkspaces` 的裸 Definition 作者只根据当前事件完成匹配,为每条关联事件提供稳定业务 id,并保证 update 能按日志 `seq` 回放;renderer 只消费最终 Node data 与受限 Location value,不扫描 Session 或 Chat 集合。完整注册和分页路径见 [Conversation Node 实操手册](../../../docs/cookbook/adding-a-conversation-node.md)。 -`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。Trajectory 不消费这两种兼容表面;在它获得独立注册 target 之前,已激活的 `session-history` inspection 继续维护独立 fold。 +`ui-conversation` 注册内建 Chat Definition 与 keyed Chat snapshot builder。append 来源的 user、assistant 和 Tool result 构成人类可见记录;仅供模型使用的 replacement 副本不进入 Chat,compaction 检查点除外,它会成为独立标记,并在更早分页补齐 summary 溯源后更新。持久 inbox splice Context 能把 next-step 用户消息判定为 steering,无须让 inbox 状态成为 Session 特例。上下文消息保留生产者 provenance 与 form。StatsLine 读取 `ConversationSnapshot.chat.legacy.nodes`;Session 则把该 legacy slice 镜像到顶层 `nodes`、`partial` 和 `runningCalls` 公共兼容字段,无须运行第二套业务 fold。`ui-trajectory` 在同一个 Session 窗口上注册独立 Definition 与 target builder;它保留现有的 stage-oriented view model,既不消费 Chat 兼容字段,也不运行另一套 history fold。 Chat builder 为每个 Session 保留一个 mutable keyed store。内容更新只通知受影响的 node key;结构变化才重建顺序和 Location 成员关系;prepend 只增加行,不替换既有 keyed value。每个 Assistant chunk 都会更新 Definition State,但最多每个 animation frame 请求一次物化;final message 与 Turn/Step 关闭会立即发布。参见 [Client Tool 展示所有权决策](../../../.agents/notes/implemented/architecture/2026-08-08-client-tool-presentation-ownership.md)。 -## 请求检查 +## Trajectory 请求数据 -`SessionHistoryInspection.requests` 是一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 +Trajectory Definition 组装出一条按时间顺序排列、以用途为判别字段的提供方请求流。助手请求始终携带数值型 `turn` 与 `step`;压缩请求携带 `step: 0`,其 `turn` 所有者可以是 `null`。这个 null 所有者表示手动压缩独立运行在两个轮次之间,并不表示它属于任一相邻轮次。`session/end-seed` 边界会在边界时刻将未匹配的压缩请求以错误状态结束,错误固定为 `Compaction was interrupted before completion.`;后续 start 会投影为独立请求,而不会覆盖这项遗留的未匹配请求。 ## Code Mode 子调用树 -每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。独立的 Trajectory history fold 仍通过 Runtime 的 `ToolCallTree` 生成同一种嵌套数据契约。 +每个 `ToolCallBlock` 都通过 `subCalls` 按启动顺序递归拥有自己的子调用。Chat 的 Tool Definition 按 call id 关联 root call 与 result,把 Code Dispatch 的 start/settlement 记录折叠进该 root Context,并投影为一棵 keyed 递归树;child call 不会成为独立 Chat root。start 落在已加载窗口之外时,其 settlement 仍以 `callTime: null` 渲染。一次 child 更新只复制其祖先链,因此未变化的 sibling 保持对象身份。会引入环或超过固定 256 层深度上限的边会被消费,但不会修改树。Trajectory 的 Tool Definition 为自己的 target 独立组装同一种嵌套数据契约。 ## Session 标题投影 diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 9f922ed956..86d51cd7a3 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-runtime", "description": "Client core services: SlotsService, SessionsService (scope tree + object layer)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/runtime" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -39,7 +46,6 @@ "@deepseek-ai/dsh-client-connection": "workspace:^", "@deepseek-ai/dsh-client-schema-form": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", - "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-host-apiproxy": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -53,10 +59,10 @@ "zustand": "~4.4.7" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-type-meta": "^0.0.1", - "@deepseek-ai/dsh-typert-registry": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -64,8 +70,8 @@ "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.18.0" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/runtime/src/client/agents/scope.ts b/packages/client/runtime/src/client/agents/scope.ts index 25644d24ba..b32840daa0 100644 --- a/packages/client/runtime/src/client/agents/scope.ts +++ b/packages/client/runtime/src/client/agents/scope.ts @@ -15,8 +15,8 @@ * — a cold session's host Agent is already disposed while its client actx * stays alive for history viewing. */ -import { Context as CordisContext } from 'cordis' -import type { Context, Fiber } from 'cordis' +import { Context as CordisContext } from '@deepseek-ai/cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { TypeRTClientRemote, TypeRTRemoteScopeApi } from '@deepseek-ai/dsh-type-meta' diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts index 9507046b33..26e7e43c67 100644 --- a/packages/client/runtime/src/client/contract/conversation.ts +++ b/packages/client/runtime/src/client/contract/conversation.ts @@ -110,6 +110,17 @@ export interface ConversationViewNode { readonly data: unknown } +/** Merge-extensible immutable snapshots published by registered view targets. */ +export interface ConversationViewSnapshotMap {} + +/** Stable reader over the latest snapshot of every registered view target. */ +export interface ConversationViewSnapshotStore { + /** @param target - registered view target. @returns its current snapshot. */ + get>( + target: Target, + ): ConversationViewSnapshotMap[Target] | undefined +} + /** Final Chat render unit produced directly by a business Definition. */ export interface ChatConversationViewNode extends ConversationViewNode { readonly target: 'chat' @@ -159,6 +170,8 @@ export type ConversationLocationDataScope = 'step' | 'turn' /** One independently registered business Event-to-Node state machine. */ export interface ConversationNodeDefinition { readonly kind: string + /** Sole view target owned by this Definition; omitted for state-only Contexts. */ + readonly target?: string /** * Extract this Definition's stable business identity from one event. * @param event - raw Session event; no Context or history access is available. @@ -207,15 +220,11 @@ export interface ConversationNodeDefinition { scope: ConversationLocationDataScope, ): ConversationLocationData | null /** - * Materialize one final Node for a registered view target. + * Materialize one final Node for this Definition's declared view target. * @param context - latest complete Context. - * @param target - registered view target such as `chat`. * @returns final Node, or null when this Context is not currently visible. */ - buildViewNode( - context: ConversationNodeContext, - target: string, - ): ConversationViewNode | null + buildViewNode?(context: ConversationNodeContext): ConversationViewNode | null } /** Reference-stable Turn/Step facts published beside view Nodes. */ diff --git a/packages/client/runtime/src/client/contract/session-history.ts b/packages/client/runtime/src/client/contract/session-history.ts deleted file mode 100644 index a48e89585e..0000000000 --- a/packages/client/runtime/src/client/contract/session-history.ts +++ /dev/null @@ -1,43 +0,0 @@ -import type { - RpcError, SessionId, -} from '@deepseek-ai/dsh-client-connection/client' -import type { SessionHistoryInspection } from '../sessions/history.ts' -import type { ObservableSnapshot } from './store.ts' - -/** Observable state of one independently loaded session history ledger. */ -export interface SessionHistorySnapshot { - state: 'cold' | 'loading' | 'ready' | 'error' - error: RpcError | null - hasMore: boolean - /** Absolute sequence of the first loaded raw event, or zero for an empty window. */ - baseSeq: number - inspection: SessionHistoryInspection -} - -/** Read-only history source addressed by session id. */ -export interface SessionHistoryFace - extends ObservableSnapshot { - readonly sessionId: SessionId - /** - * Load the current tail without reading older pages. - * @param signal - Consumer lifetime. - * @returns When the tail is ready or loading fails. - */ - loadTail(signal?: AbortSignal): Promise - /** - * Prepend one older page when the current window has a predecessor. - * @param signal - Consumer lifetime. - * @returns Whether the loaded window advanced. - */ - loadOlder(signal?: AbortSignal): Promise -} - -/** Runtime service resolving independent history sources. */ -export interface ISessionHistory { - /** - * Resolve the identity-stable source for a session. - * @param sessionId - Host session identity. - * @returns The source owned outside Session and SessionManager. - */ - source(sessionId: SessionId): SessionHistoryFace -} diff --git a/packages/client/runtime/src/client/contract/sessions.ts b/packages/client/runtime/src/client/contract/sessions.ts index 1560f131d2..960d2038de 100644 --- a/packages/client/runtime/src/client/contract/sessions.ts +++ b/packages/client/runtime/src/client/contract/sessions.ts @@ -7,7 +7,7 @@ * [SessionsPort](./sessions-port.ts). Widening this interface is the * explicit act of widening what features may do to the sessions domain. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { RpcResult, SessionId, SubagentAddress, } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/client/runtime/src/client/conversation/definition-registry.ts b/packages/client/runtime/src/client/conversation/definition-registry.ts index d43f494e1a..425f426512 100644 --- a/packages/client/runtime/src/client/conversation/definition-registry.ts +++ b/packages/client/runtime/src/client/conversation/definition-registry.ts @@ -1,4 +1,4 @@ -import { Service } from 'cordis' +import { Service } from '@deepseek-ai/cordis' /** Shared lifecycle and stable-entry storage for one Conversation Definition registry. */ export abstract class ConversationDefinitionRegistry extends Service { diff --git a/packages/client/runtime/src/client/conversation/event-registry.ts b/packages/client/runtime/src/client/conversation/event-registry.ts index 6935ed1741..d9eabda538 100644 --- a/packages/client/runtime/src/client/conversation/event-registry.ts +++ b/packages/client/runtime/src/client/conversation/event-registry.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition } from '../contract/conversation.ts' import { ConversationDefinitionRegistry } from './definition-registry.ts' @@ -17,6 +17,7 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry void { + assertDefinitionTarget(definition) return this.registerDefinition( definition.kind, definition, @@ -31,6 +32,9 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry void { + assertDefinitionTarget(definition) + const target = definition.target + if (target === undefined) throw new Error('conversation fallback Definition must declare a target') if (this.fallback !== undefined) throw new Error('conversation fallback Definition is already registered') const owner = this.ctx const dispose = owner.effect(() => { @@ -52,5 +56,12 @@ export class ConversationEventRegistry extends ConversationDefinitionRegistry sessions.scopeOf(candidate), }) - const sessionHistory = new SessionHistoryService(ctx, connection.api) const workspaces = new WorkspacesService(ctx, connection.api, sessions) ctx.effect( () => workspaces.startInitialSelection(), @@ -244,11 +237,6 @@ export function apply(ctx: Context): void { const loop = connection.start({ onMuxEnvelope: (envelope) => { sessions.handleMuxEnvelope(envelope) - try { - sessionHistory.handleMuxEnvelope(envelope) - } catch (error) { - console.error('[web-runtime] history frame routing failed:', error) - } }, onHostEnvelope: (envelope) => { sessions.handleHostEnvelope(envelope) @@ -264,21 +252,11 @@ export function apply(ctx: Context): void { else if (frame.type === 'host/settings-changed') ctx.emit('settings/changed', frame.ns) else if (frame.type === 'host/credentials-changed') ctx.emit('credentials/changed', frame.ref) else if (frame.type === 'host/models-changed') ctx.emit('models/changed') - try { - sessionHistory.handleHostEnvelope(envelope) - } catch (error) { - console.error('[web-runtime] history host-frame routing failed:', error) - } }, onConnected: () => { sessions.handleConnected() workspaces.handleConnected() ctx.emit('connection/reset') - try { - sessionHistory.handleConnected() - } catch (error) { - console.error('[web-runtime] history reconnect failed:', error) - } }, onStateChange: (state) => { // Generation death fires before any next-generation frame can arrive @@ -286,11 +264,6 @@ export function apply(ctx: Context): void { // the only safe moment to drop generation-scoped interaction state. if (state === 'reconnecting') { sessions.handleDisconnected() - try { - sessionHistory.handleDisconnected() - } catch (error) { - console.error('[web-runtime] history disconnect failed:', error) - } } }, }) diff --git a/packages/client/runtime/src/client/session-history/history-fold.ts b/packages/client/runtime/src/client/session-history/history-fold.ts deleted file mode 100644 index 42aaa37027..0000000000 --- a/packages/client/runtime/src/client/session-history/history-fold.ts +++ /dev/null @@ -1,428 +0,0 @@ -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { - SurfaceManager, isSurfaceEligibleType, isSurfaceEvent, -} from '@deepseek-ai/dsh-session/surface' -import type { - HistoryEntry, ToolCallView, ToolResultView, -} from '@deepseek-ai/dsh-client-connection/client' -import type { - AssistantRequestConfig, AssistantTiming, ConversationNode, - PartialAssistant, RunningToolCall, -} from '../sessions/conversation.ts' -import { toAssistantBlocks } from '../sessions/conversation.ts' -import { contextForm, contextProvenance } from '../sessions/context-provenance.ts' -import { SteeringHistory } from '../sessions/steering-history.ts' -import type { - ConversationContext, ConversationContextOriginKind, -} from '../sessions/conversation-context.ts' -import type { ConversationPromptSnapshot } from '../sessions/request-inspection.ts' -import { PartialAccumulator } from '../sessions/partial.ts' -import type { AssistantStepMetadata } from '../sessions/assistant-timing.ts' -import { indexAssistantStepTiming, settledAssistantTiming } from '../sessions/assistant-timing.ts' -import { ToolCallTree } from '../sessions/tool-call-tree.ts' - -interface CallIndexEntry { - name: string - argsRaw: string - time: number - callView: ToolCallView | null -} - -interface FoldedContext { - generation: number - nodes: readonly number[] - originSeq?: number -} - -/** Immutable conversation projections derived only from the history source. */ -export interface ConversationHistoryProjection { - eventNodes: readonly ConversationNode[] - contexts: readonly ConversationContext[] - interruptedNodes: readonly ConversationNode[] - partial: PartialAssistant | null - runningCalls: readonly RunningToolCall[] -} - -function replacementCrossesWindowHead(event: SessionEvent, baseSeq: number): boolean { - if (!isSurfaceEvent(event) || event.surfaceOp === 'append') return false - return event.surfaceOp.start < baseSeq || event.surfaceOp.end < baseSeq -} - -function contextOriginKind(event: SessionEvent | undefined): ConversationContextOriginKind { - if (event?.type !== 'user/message') return 'rewrite' - const source = event.data.source - if (typeof source === 'object' && 'kind' in source && 'plugin' in source) { - if (source.plugin === 'compact') return 'compaction' - if (source.plugin === 'rewind') return 'rewind' - } - return 'rewrite' -} - -function foldContexts(events: readonly SessionEvent[]): readonly FoldedContext[] { - const replay: SessionEvent[] = [] - const originalSeqs: number[] = [] - const rebasedSeqByOriginal = new Map() - const surface = new SurfaceManager(replay) - const contexts: FoldedContext[] = [] - let generation = 0 - let originSeq: number | undefined - const originalNodes = () => surface.nodes.map((seq) => { - const original = originalSeqs[seq] - if (original === undefined) throw new Error(`rebased surface seq ${seq} has no origin`) - return original - }) - for (const event of events) { - if (!isSurfaceEvent(event)) continue - if (event.surfaceOp !== 'append') { - contexts.push({ - generation, - nodes: originalNodes(), - ...(originSeq === undefined ? {} : { originSeq }), - }) - generation++ - originSeq = event.seq - } - const rebasedSeq = replay.length - const { - sourceEventSeqs: rawSources, - ...eventWithoutSources - } = event as SessionEvent & { sourceEventSeqs?: readonly number[] } - const mappedSourceEventSeqs = rawSources?.flatMap((seq) => { - const rebased = rebasedSeqByOriginal.get(seq) - return rebased === undefined ? [] : [rebased] - }) - const sourceEventSeqs = mappedSourceEventSeqs?.length === 0 - ? undefined - : mappedSourceEventSeqs - const surfaceOp = event.surfaceOp === 'append' - ? event.surfaceOp - : { - ...event.surfaceOp, - start: rebasedSeqByOriginal.get(event.surfaceOp.start) ?? event.surfaceOp.start, - end: rebasedSeqByOriginal.get(event.surfaceOp.end) ?? event.surfaceOp.end, - } - originalSeqs.push(event.seq) - rebasedSeqByOriginal.set(event.seq, rebasedSeq) - replay.push({ - ...eventWithoutSources, - seq: rebasedSeq, - surfaceOp, - ...(sourceEventSeqs === undefined ? {} : { sourceEventSeqs }), - } as SessionEvent) - } - contexts.push({ - generation, - nodes: originalNodes(), - ...(originSeq === undefined ? {} : { originSeq }), - }) - return contexts -} - -// History projection owns its node mapping so Chat's live adapter remains free -// of inspection metadata and lifecycle coupling. -/* jscpd:ignore-start */ -function materializeNode( - event: SessionEvent, - callIndex: ReadonlyMap, - resultView: ToolResultView | null, - assistantTiming: AssistantTiming | undefined, - requestConfig: AssistantRequestConfig | undefined, - steering: boolean, -): ConversationNode { - switch (event.type) { - case 'user/message': - if (event.data.source.kind !== 'user') { - return { - kind: 'context', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - provenance: contextProvenance(event.data.source), - form: contextForm(event.data.source), - } - } - if (steering) { - return { - kind: 'steering', messageId: event.data.id, - seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - } - } - return { - kind: 'user', seq: event.seq, time: event.time, - content: event.data.content, source: event.data.source, - } - case 'assistant/message': - return { - kind: 'assistant', seq: event.seq, time: event.time, - turn: event.data.turn, step: event.data.step, - blocks: toAssistantBlocks(event.data.message.content), usage: event.data.usage, - provenance: { - provider: event.data.message.source.provider, - model: event.data.message.source.model, - }, - ...(requestConfig === undefined ? {} : { requestConfig }), - ...(assistantTiming === undefined ? {} : { timing: assistantTiming }), - } - case 'tool/result': { - const result = event.data.message.content[0] - const callId = String(event.data.message.source.callId) - const call = callIndex.get(callId) - return { - kind: 'tool-result', seq: event.seq, time: event.time, - callId, - call: call === undefined ? null : { name: call.name, argsRaw: call.argsRaw }, - callTime: call?.time ?? null, - content: result.content, isError: result.isError === true, - ...(event.data.error === undefined ? {} : { error: event.data.error }), - meta: event.data.meta, - callView: call?.callView ?? null, - resultView, - subCalls: [], - } - } - default: - return { - kind: 'unknown', seq: event.seq, time: event.time, - type: event.type, data: (event as { data?: unknown }).data, - } - } -} -/* jscpd:ignore-end */ - -interface TransientProjection extends Pick< - ConversationHistoryProjection, - 'interruptedNodes' | 'partial' | 'runningCalls' -> { - toolCallTree: ToolCallTree -} - -function projectTransient(entries: readonly HistoryEntry[]): TransientProjection { - let partial: PartialAccumulator | null = null - const openCalls = new Map() - const interruptedNodes: ConversationNode[] = [] - const toolCallTree = new ToolCallTree() - - for (const entry of entries) { - const { event } = entry - if (toolCallTree.apply(event)) continue - switch (event.type) { - case 'assistant/chunk': { - const { turn, step, chunk } = event.data - if (partial === null || partial.turn !== turn || partial.step !== step) { - partial = new PartialAccumulator(turn, step) - } - partial.push(chunk) - break - } - case 'assistant/message': - if (partial?.turn === event.data.turn && partial.step === event.data.step) partial = null - break - case 'tool/call': - // History reconstructs its own in-flight index; this intentionally - // mirrors the published Chat node shape, not Chat's mutable state. - /* jscpd:ignore-start */ - openCalls.set(String(event.data.callId), { - callId: String(event.data.callId), - name: event.data.name, - argsRaw: event.data.arguments, - turn: event.data.turn, - step: event.data.step, - time: event.time, - callView: entry.view?.for === 'call' ? entry.view.view : null, - subCalls: [], - }) - /* jscpd:ignore-end */ - break - case 'tool/result': - openCalls.delete(String(event.data.message.source.callId)) - break - case 'turn/end': { - if (partial !== null && partial.turn === event.data.turn) { - const { blocks } = partial.toPartial() - const visible = blocks.some(block => - block.kind === 'text' || block.kind === 'reasoning' ? block.text !== '' : true) - if (visible) { - interruptedNodes.push({ - kind: 'assistant', seq: event.seq - 0.9, time: event.time, - turn: partial.turn, step: partial.step, blocks, interrupted: true, - }) - } - partial = null - } - let callOffset = 0 - for (const [callId, call] of openCalls) { - if (call.turn !== event.data.turn) continue - openCalls.delete(callId) - // Interrupted terminal nodes are reconstructed independently so a - // Trajectory replay cannot observe Session's frozen-node lifecycle. - /* jscpd:ignore-start */ - interruptedNodes.push({ - kind: 'tool-result', seq: event.seq - 0.8 + callOffset++ * 0.01, - time: event.time, - callId, - call: { name: call.name, argsRaw: call.argsRaw }, - callTime: call.time, - content: [], - isError: true, - error: { name: 'Interrupted', code: 'interrupted' }, - callView: call.callView, - resultView: null, - subCalls: [], - }) - /* jscpd:ignore-end */ - } - break - } - default: - break - } - } - - return { - interruptedNodes, - partial: partial?.toPartial() ?? null, - runningCalls: [...openCalls.values()], - toolCallTree, - } -} - -/** - * Project one immutable history ledger without reading or mutating Chat state. - * @param entries - Contiguous history entries in sequence order. - * @returns Event order, context lineage, and transient tail state. - */ -export function projectConversationHistory( - entries: readonly HistoryEntry[], -): ConversationHistoryProjection { - const events = entries.map(entry => entry.event) - const steeringHistory = new SteeringHistory() - const steeringSeqs = new Set() - for (const event of events) { - if (steeringHistory.apply(event)) steeringSeqs.add(event.seq) - } - const baseSeq = events[0]?.seq ?? 0 - const eventsBySeq = new Map(events.map(event => [event.seq, event])) - const callIndex = new Map() - const resultViews = new Map() - const assistantSteps = new Map() - const assistantTimings = new Map() - const assistantRequestConfigs = new Map() - const promptsByContext = new Map() - let activeRequestConfig: AssistantRequestConfig | undefined - let activePrompt: ConversationPromptSnapshot | undefined - let contextGeneration = 0 - - for (const [index, event] of events.entries()) { - const view = entries[index]?.view - if (event.type === 'tool/call') { - callIndex.set(String(event.data.callId), { - name: event.data.name, - argsRaw: event.data.arguments, - time: event.time, - callView: view?.for === 'call' ? view.view : null, - }) - } else if (event.type === 'tool/result' && view?.for === 'result') { - resultViews.set(event.seq, view.view) - } - if (isSurfaceEvent(event) && event.surfaceOp !== 'append') { - contextGeneration++ - if (activePrompt !== undefined) promptsByContext.set(contextGeneration, activePrompt) - } - indexAssistantStepTiming(assistantSteps, event) - if (event.type === 'request/header') { - activeRequestConfig = event.data.header.config - activePrompt = { - config: event.data.header.config, - system: event.data.header.system ?? '', - tools: event.data.header.tools ?? [], - } - promptsByContext.set(contextGeneration, activePrompt) - } else if (event.type === 'assistant/message') { - assistantTimings.set( - event.seq, - settledAssistantTiming(assistantSteps, event.data.turn, event.data.step, event.time), - ) - if (activeRequestConfig !== undefined) { - assistantRequestConfigs.set(event.seq, activeRequestConfig) - } - } - } - - const nodeCache = new Map() - const materialize = (seq: number): ConversationNode | undefined => { - const cached = nodeCache.get(seq) - if (cached !== undefined) return cached - const event = eventsBySeq.get(seq) - if (event === undefined || !isSurfaceEligibleType(event.type)) return - const node = materializeNode( - event, - callIndex, - resultViews.get(seq) ?? null, - assistantTimings.get(seq), - assistantRequestConfigs.get(seq), - steeringSeqs.has(seq), - ) - nodeCache.set(seq, node) - return node - } - const eventNodes = events.flatMap((event) => { - const node = materialize(event.seq) - return node === undefined ? [] : [node] - }) - - let contexts: readonly ConversationContext[] - if (events.some(event => replacementCrossesWindowHead(event, baseSeq))) { - contexts = [{ - id: 0, - ...(activePrompt === undefined ? {} : { prompt: activePrompt }), - nodes: eventNodes, - }] - } else { - try { - contexts = foldContexts(events).map((context): ConversationContext => { - const nodes = context.nodes.flatMap((seq) => { - const node = materialize(seq) - return node === undefined ? [] : [node] - }) - const prompt = promptsByContext.get(context.generation) - if (context.originSeq === undefined) { - return { - id: context.generation, - ...(prompt === undefined ? {} : { prompt }), - nodes, - } - } - const originEvent = eventsBySeq.get(context.originSeq) - return { - id: context.generation, - parentId: context.generation - 1, - origin: contextOriginKind(originEvent), - originSeq: context.originSeq, - ...(originEvent === undefined ? {} : { createdAt: originEvent.time }), - ...(prompt === undefined ? {} : { prompt }), - nodes, - } - }) - } catch (error) { - console.error('[web-runtime] history surface fold failed, using event order:', error) - contexts = [{ - id: 0, - ...(activePrompt === undefined ? {} : { prompt: activePrompt }), - nodes: eventNodes, - }] - } - } - - const transient = projectTransient(entries) - const projectedEventNodes = transient.toolCallTree.projectNodes(eventNodes) - const projectedContexts = contexts.map((context): ConversationContext => { - const nodes = transient.toolCallTree.projectNodes(context.nodes) - return nodes === context.nodes ? context : { ...context, nodes } - }) - return { - eventNodes: projectedEventNodes, - contexts: projectedContexts, - interruptedNodes: transient.toolCallTree.projectNodes(transient.interruptedNodes), - partial: transient.partial, - runningCalls: transient.toolCallTree.projectRunningCalls(transient.runningCalls), - } -} diff --git a/packages/client/runtime/src/client/session-history/service.ts b/packages/client/runtime/src/client/session-history/service.ts deleted file mode 100644 index b5aba32bce..0000000000 --- a/packages/client/runtime/src/client/session-history/service.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Context } from 'cordis' -import type { - HostFrame, IApiClient, MuxFrame, RpcRequest, SessionId, -} from '@deepseek-ai/dsh-client-connection/client' -import type { - ISessionHistory, SessionHistoryFace, -} from '../contract/session-history.ts' -import { SessionHistorySource } from './source.ts' - -/** Root registry and frame router for independent inspection histories. */ -export class SessionHistoryService implements ISessionHistory { - private readonly sources = new Map() - - /** - * @param ctx - Client root context. - * @param api - Shared wire client. - */ - constructor(ctx: Context, private readonly api: IApiClient) { - ctx.reflect.provide('sessionHistory', this, undefined) - } - - /** - * Resolve one identity-stable history source. - * @param sessionId - Host session identity. - * @returns Source independent from SessionManager. - */ - source(sessionId: SessionId): SessionHistoryFace { - let source = this.sources.get(sessionId) - if (source === undefined) { - source = new SessionHistorySource(sessionId, this.api) - this.sources.set(sessionId, source) - } - return source - } - - /** - * Route history-relevant mux frames only to an existing source. - * @param envelope - Validated mux envelope. - */ - handleMuxEnvelope(envelope: RpcRequest): void { - const frame = envelope.payload - if (frame.type === 'stream/error') return - this.sources.get(frame.sessionId)?.handleMuxFrame(frame) - } - - /** - * Drop a removed session's independent history source. - * @param envelope - Validated host envelope. - */ - handleHostEnvelope(envelope: RpcRequest): void { - const frame = envelope.payload - if (frame.type !== 'host/session-removed') return - this.sources.get(frame.sessionId)?.dispose() - this.sources.delete(frame.sessionId) - } - - /** Invalidate requests from the dead connection generation. */ - handleDisconnected(): void { - for (const source of this.sources.values()) source.handleDisconnected() - } - - /** Rebuild every previously activated source from the new generation. */ - handleConnected(): void { - for (const source of this.sources.values()) source.resync() - } -} diff --git a/packages/client/runtime/src/client/session-history/source.ts b/packages/client/runtime/src/client/session-history/source.ts deleted file mode 100644 index 44e760b2b2..0000000000 --- a/packages/client/runtime/src/client/session-history/source.ts +++ /dev/null @@ -1,432 +0,0 @@ -import type { - HistoryEntry, IApiClient, MuxFrame, RpcError, SessionId, -} from '@deepseek-ai/dsh-client-connection/client' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { transportError } from '@deepseek-ai/dsh-host-apiproxy/api' -import type { - SessionHistoryFace, SessionHistorySnapshot, -} from '../contract/session-history.ts' -import { - compactHistoryInspectionEntries, createHistoryInspection, -} from '../sessions/history.ts' -import { Notifier } from '../sessions/notifier.ts' -import { isVisibleAssistantChunk, PartialAccumulator } from '../sessions/partial.ts' - -const HISTORY_PAGE_MESSAGES = 50 - -function isAborted(signal: AbortSignal | undefined): boolean { - return signal?.aborted === true -} - -/** Independent raw-history owner used only by inspection consumers. */ -export class SessionHistorySource implements SessionHistoryFace { - private entries: HistoryEntry[] = [] - private inspectionEntries: readonly HistoryEntry[] = [] - private baseSeq = 0 - private hasMore = false - private state: SessionHistorySnapshot['state'] = 'cold' - private error: RpcError | null = null - private generation = 0 - private persistentConsumer = false - private readonly consumerSignals = new Set() - private openPromise: Promise | null = null - private olderPromise: Promise | null = null - private stitching = false - private liveBuffer: HistoryEntry[] = [] - private subscribedLastSeq: number | null = null - private inspectionCache: { - entries: readonly HistoryEntry[] - value: SessionHistorySnapshot['inspection'] - } | null = null - private streamPublishToken: object | null = null - private streamPartial: PartialAccumulator | null = null - private snapshotCache: SessionHistorySnapshot - private readonly notifier = new Notifier(() => { - this.snapshotCache = this.buildSnapshot() - }) - - /** - * @param sessionId - Host session identity. - * @param api - Shared wire client. - */ - constructor( - readonly sessionId: SessionId, - private readonly api: IApiClient, - ) { - this.snapshotCache = this.buildSnapshot() - } - - /** - * Subscribe to ledger changes. - * @param listener - Change callback. - * @returns Unsubscribe function. - */ - subscribe(listener: () => void): () => void { - return this.notifier.subscribe(listener) - } - - /** - * Read the cached ledger snapshot. - * @returns Stable snapshot until the source changes. - */ - getSnapshot(): SessionHistorySnapshot { - this.notifier.ensureFresh() - return this.snapshotCache - } - - /** - * Load the current tail without reading older pages. - * @param signal - Consumer lifetime. - * @returns When the tail is ready or loading fails. - */ - async loadTail(signal?: AbortSignal): Promise { - if (isAborted(signal)) return - this.trackConsumer(signal) - await this.open() - } - - /** - * Prepend one older page when the current window has a predecessor. - * @param signal - Consumer lifetime. - * @returns Whether the loaded window advanced. - */ - async loadOlder(signal?: AbortSignal): Promise { - if (isAborted(signal)) return false - this.trackConsumer(signal) - await this.open() - if (isAborted(signal)) return false - const previousBaseSeq = this.baseSeq - await this.loadOlderPage() - return this.baseSeq !== previousBaseSeq - } - - /** - * Route a relevant mux frame without involving the Chat session. - * @param frame - Session-addressed frame. - */ - handleMuxFrame(frame: MuxFrame): void { - if (frame.type === 'session/subscribed') { - this.subscribedLastSeq = frame.lastSeq - return - } - if (frame.type !== 'session/event') return - this.acceptLive({ event: frame.event, ...(frame.view === undefined ? {} : { view: frame.view }) }) - } - - /** Invalidate dead-generation requests while retaining the last readable snapshot. */ - handleDisconnected(): void { - this.generation++ - this.openPromise = null - this.olderPromise = null - this.stitching = false - this.liveBuffer = [] - this.subscribedLastSeq = null - if (this.state !== 'cold') { - this.state = 'cold' - this.error = null - this.publishDirtyNow() - } - } - - /** Rebuild an activated ledger from the new connection generation. */ - resync(): void { - if (!this.hasConsumer()) return - this.generation++ - this.openPromise = null - this.olderPromise = null - this.stitching = false - this.liveBuffer = [] - this.subscribedLastSeq = null - this.entries = [] - this.inspectionEntries = [] - this.baseSeq = 0 - this.hasMore = false - this.state = 'cold' - this.error = null - this.publishDirtyNow() - void this.open() - } - - /** Stop future refresh work after the host removes the session. */ - dispose(): void { - this.persistentConsumer = false - this.consumerSignals.clear() - this.generation++ - this.openPromise = null - this.olderPromise = null - this.liveBuffer = [] - this.streamPublishToken = null - this.streamPartial = null - } - - private open(): Promise { - if (this.state === 'ready') return Promise.resolve() - if (this.openPromise !== null) return this.openPromise - const generation = this.generation - const operation = this.doOpen(generation) - const settled = operation.finally(() => { - if (this.openPromise === settled) this.openPromise = null - }) - this.openPromise = settled - return settled - } - - private trackConsumer(signal: AbortSignal | undefined): void { - if (signal === undefined) { - this.persistentConsumer = true - return - } - if (this.consumerSignals.has(signal)) return - this.consumerSignals.add(signal) - signal.addEventListener('abort', () => { - this.consumerSignals.delete(signal) - }, { once: true }) - } - - private hasConsumer(): boolean { - return this.persistentConsumer || this.consumerSignals.size > 0 - } - - private async doOpen(generation: number): Promise { - this.state = 'loading' - this.error = null - this.publishDirtyNow() - try { - let { result } = await this.api.sessions.history({ - sessionId: this.sessionId, - maxMessages: HISTORY_PAGE_MESSAGES, - }) - if (generation !== this.generation) return - if (!result.ok) { - this.state = 'error' - this.error = result.error - return - } - this.installTail(result.value.events, result.value.hasMore, true) - const tailSeq = this.tailSeq() - if ( - this.subscribedLastSeq !== null - && tailSeq !== null - && this.subscribedLastSeq > tailSeq - ) { - result = (await this.api.sessions.history({ - sessionId: this.sessionId, - maxMessages: HISTORY_PAGE_MESSAGES, - })).result - if (generation !== this.generation) return - if (result.ok) this.installTail(result.value.events, result.value.hasMore, true) - } - this.state = 'ready' - } catch (error) { - if (generation !== this.generation) return - this.state = 'error' - const folded = transportError(error) - /* v8 ignore next -- transportError always returns the error branch. */ - this.error = folded.ok ? null : folded.error - } finally { - if (generation === this.generation) this.publishDirtyNow() - } - } - - private loadOlderPage(): Promise { - if (this.olderPromise !== null) return this.olderPromise - if (this.state !== 'ready' || !this.hasMore) return Promise.resolve() - const generation = this.generation - const operation = (async () => { - try { - const { result } = await this.api.sessions.history({ - sessionId: this.sessionId, - beforeSeq: this.baseSeq, - maxMessages: HISTORY_PAGE_MESSAGES, - }) - if (generation !== this.generation || this.state !== 'ready' || !result.ok) return - const older = result.value.events - if (older.length === 0) { - this.hasMore = result.value.hasMore - return - } - const tail = older.at(-1) - if (tail === undefined || tail.event.seq + 1 !== this.baseSeq) { - console.error( - `[web-runtime] inspection history page discontinuous: tail seq ${tail?.event.seq} vs baseSeq ${this.baseSeq}`, - ) - this.hasMore = false - return - } - this.entries = [...older, ...this.entries] - this.inspectionEntries = compactHistoryInspectionEntries([...this.entries]) - this.baseSeq = older[0]?.event.seq ?? this.baseSeq - this.hasMore = result.value.hasMore - } catch (error) { - console.error('[web-runtime] inspection history paging failed:', error) - } - })() - const settled = operation.finally(() => { - if (this.olderPromise !== settled) return - this.olderPromise = null - this.publishDirtyNow() - }) - this.olderPromise = settled - return settled - } - - private installTail( - tail: readonly HistoryEntry[], - hasMore: boolean, - replace: boolean, - ): void { - if (replace) { - this.entries = [...tail] - this.hasMore = hasMore - } else { - const firstSeq = tail[0]?.event.seq - const prefix = firstSeq === undefined - ? this.entries - : this.entries.filter(entry => entry.event.seq < firstSeq) - this.entries = [...prefix, ...tail] - } - this.baseSeq = this.entries[0]?.event.seq ?? 0 - this.inspectionEntries = compactHistoryInspectionEntries([...this.entries]) - const buffered = this.liveBuffer - this.liveBuffer = [] - for (const entry of buffered) this.appendLive(entry) - this.publishDirtyNow() - } - - private acceptLive(entry: HistoryEntry): void { - if (this.state === 'loading' || this.stitching) { - this.liveBuffer.push(entry) - return - } - if (this.state !== 'ready') return - const tailSeq = this.tailSeq() - if (tailSeq !== null && entry.event.seq > tailSeq + 1) { - this.liveBuffer.push(entry) - void this.repairGap() - return - } - if ( - entry.event.type === 'assistant/chunk' - && entry.event.data.chunk.type !== 'usage' - ) { - if (!this.appendIncrementalChunk(entry, entry.event)) return - this.publishStreamDirty() - return - } - this.appendLive(entry) - this.publishDirtyNow() - } - - private appendLive(entry: HistoryEntry): void { - const tailSeq = this.tailSeq() - if (tailSeq !== null && entry.event.seq <= tailSeq) return - this.entries.push(entry) - this.inspectionEntries = [...this.inspectionEntries, entry] - if (entry.event.type === 'assistant/message') { - this.inspectionEntries = compactHistoryInspectionEntries(this.inspectionEntries) - } - } - - /** Append a chunk against the cached finalized projection; false means no visible publish. */ - private appendIncrementalChunk( - entry: HistoryEntry, - event: SessionEvent<'assistant/chunk'>, - ): boolean { - const { turn, step, chunk } = event.data - if (!isVisibleAssistantChunk(chunk.type)) { - const inspection = this.currentInspection() - this.appendLive(entry) - this.inspectionCache = { entries: this.inspectionEntries, value: inspection } - return false - } - const base = this.currentInspection() - if ( - this.streamPartial === null - || this.streamPartial.turn !== turn - || this.streamPartial.step !== step - ) { - const current = base.partial - this.streamPartial = new PartialAccumulator( - turn, - step, - current?.turn === turn && current.step === step ? current.blocks : [], - ) - } - this.streamPartial.push(chunk) - this.appendLive(entry) - this.inspectionCache = { - entries: this.inspectionEntries, - value: { ...base, partial: this.streamPartial.toPartial() }, - } - return true - } - - /** Coalesce token-stream projection and rendering work to one publish per browser frame. */ - private publishStreamDirty(): void { - if (this.streamPublishToken !== null) return - const token = {} - this.streamPublishToken = token - const publish = () => { - if (this.streamPublishToken !== token) return - this.streamPublishToken = null - this.notifier.markDirty() - } - if (typeof globalThis.requestAnimationFrame === 'function') { - globalThis.requestAnimationFrame(publish) - } else { - queueMicrotask(publish) - } - } - - /** Publish structural changes immediately and invalidate an older scheduled stream publish. */ - private publishDirtyNow(): void { - this.streamPublishToken = null - this.streamPartial = null - this.notifier.markDirty() - } - - private async repairGap(): Promise { - if (this.stitching) return - this.stitching = true - const generation = this.generation - try { - const { result } = await this.api.sessions.history({ - sessionId: this.sessionId, - maxMessages: HISTORY_PAGE_MESSAGES, - }) - if (result.ok && generation === this.generation && this.state === 'ready') { - this.installTail(result.value.events, result.value.hasMore, false) - } - } catch (error) { - console.error('[web-runtime] inspection history gap repair failed:', error) - } finally { - if (generation === this.generation) this.stitching = false - } - } - - private tailSeq(): number | null { - return this.entries.at(-1)?.event.seq ?? null - } - - private buildSnapshot(): SessionHistorySnapshot { - return { - state: this.state, - error: this.error, - hasMore: this.hasMore, - baseSeq: this.baseSeq, - inspection: this.currentInspection(), - } - } - - /** Inspection pinned to the source's current immutable entry array. */ - private currentInspection(): SessionHistorySnapshot['inspection'] { - if (this.inspectionCache?.entries !== this.inspectionEntries) { - const entries = this.inspectionEntries - this.inspectionCache = { - entries, - value: createHistoryInspection(() => entries), - } - } - return this.inspectionCache.value - } -} diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts index bdd89f56a4..85c59a4053 100644 --- a/packages/client/runtime/src/client/sessions/conversation-assembler.ts +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -2,7 +2,8 @@ import type { ConversationContextReader, ConversationEventInput, ConversationLocationData, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, ConversationPreviousContext, ConversationLocationDataScope, ConversationPublication, ConversationViewBuilder, - ConversationViewDefinition, ConversationViewNode, + ConversationViewDefinition, ConversationViewNode, ConversationViewSnapshotMap, + ConversationViewSnapshotStore, } from '../contract/conversation.ts' import { conversationContextKey } from '../contract/conversation.ts' import { @@ -133,7 +134,7 @@ export interface ConversationViewDefinitions { * Session-owned incremental engine that assembles business Contexts from a * contiguous Event window and materializes registered view snapshots. */ -export class ConversationNodeAssembler { +export class ConversationNodeAssembler implements ConversationViewSnapshotStore { private readonly contexts = new Map() private readonly contextsByKind = new Map() private readonly contextsBySeq = new Map>() @@ -266,11 +267,11 @@ export class ConversationNodeAssembler { const allByTarget = new Map() for (const target of this.views.keys()) allByTarget.set(target, []) for (const context of this.contexts.values()) { - for (const target of this.views.keys()) { - const node = this.buildNode(context, target) - context.current.set(target, node) - if (node !== null) allByTarget.get(target)?.push(node) - } + const target = context.definition.target + if (target === undefined || !this.views.has(target)) continue + const node = this.buildNode(context, target) + context.current.set(target, node) + if (node !== null) allByTarget.get(target)?.push(node) } for (const view of this.views.values()) { view.snapshot = view.builder.replace({ @@ -288,17 +289,17 @@ export class ConversationNodeAssembler { for (const target of this.views.keys()) upsertsByTarget.set(target, []) if (this.applyDirtyLocationData()) this.timelineDirty = true for (const context of this.dirty) { - for (const target of this.views.keys()) { - const previous = context.current.get(target) ?? null - const node = this.buildNode(context, target) - if (node === null && previous !== null) { - throw new Error( - `conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`, - ) - } - context.current.set(target, node) - if (node !== null) upsertsByTarget.get(target)?.push(node) + const target = context.definition.target + if (target === undefined || !this.views.has(target)) continue + const previous = context.current.get(target) ?? null + const node = this.buildNode(context, target) + if (node === null && previous !== null) { + throw new Error( + `conversation Definition "${context.kind}" withdrew materialized target "${target}"; return the same key with hidden visibility instead`, + ) } + context.current.set(target, node) + if (node !== null) upsertsByTarget.get(target)?.push(node) } this.dirty.clear() const timelineDirty = this.timelineDirty @@ -323,6 +324,12 @@ export class ConversationNodeAssembler { return this.views.get(target)?.snapshot } + get>( + target: Target, + ): ConversationViewSnapshotMap[Target] | undefined { + return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined + } + private sortedInputs(): ConversationEventInput[] { return [...this.inputs.values()].sort((left, right) => left.event.seq - right.event.seq) } @@ -358,18 +365,19 @@ export class ConversationNodeAssembler { role: ConversationMatch['role'], ) => ConversationPublication, ): ConversationPublication { - let matched = false + const matchedTargets = new Set() let publication: ConversationPublication = 'none' for (const definition of this.eventDefinitions.entries()) { const result = definition.match(input.event) if (result === null) continue - matched = true + if (definition.target !== undefined) matchedTargets.add(definition.target) publication = maximumPublication(publication, accept(definition, result.id, result.role)) } - if (!matched) { - const fallback = this.eventDefinitions.fallbackEntry() - const result = fallback?.match(input.event) ?? null - if (fallback !== undefined && result !== null) { + const fallback = this.eventDefinitions.fallbackEntry() + const target = fallback?.target + if (fallback !== undefined && target !== undefined && !matchedTargets.has(target)) { + const result = fallback.match(input.event) + if (result !== null) { publication = maximumPublication(publication, accept(fallback, result.id, result.role)) } } @@ -697,7 +705,8 @@ export class ConversationNodeAssembler { } private buildNode(context: InternalContext, target: string): ConversationViewNode | null { - const node = context.definition.buildViewNode(contextSnapshot(context), target) + if (context.definition.target !== target || context.definition.buildViewNode === undefined) return null + const node = context.definition.buildViewNode(contextSnapshot(context)) if (node === null) return null if (node.key !== context.key) { throw new Error(`conversation Definition "${context.kind}" returned unstable key "${node.key}"; expected "${context.key}"`) diff --git a/packages/client/runtime/src/client/sessions/conversation.ts b/packages/client/runtime/src/client/sessions/conversation.ts index a14d6fbb96..4397013dab 100644 --- a/packages/client/runtime/src/client/sessions/conversation.ts +++ b/packages/client/runtime/src/client/sessions/conversation.ts @@ -17,7 +17,7 @@ import type { import type { PendingInteraction } from './pending.ts' import type { ContextProvenanceView, KnownContextForm } from './context-provenance.ts' import type { - ChatConversationViewNode, ConversationTimelineSnapshot, + ChatConversationViewNode, ConversationTimelineSnapshot, ConversationViewSnapshotStore, } from '../contract/conversation.ts' export type { TodoItem } @@ -384,6 +384,11 @@ export interface ChatSnapshot { const EMPTY_LIST: readonly never[] = [] const EMPTY_TIMELINE: ConversationTimelineSnapshot = { turnOrder: EMPTY_LIST, turns: new Map() } +/** Empty target store used by fixtures and Sessions without registered views. */ +export const EMPTY_CONVERSATION_VIEWS: ConversationViewSnapshotStore = { + get: () => undefined, +} + /** Empty Chat target used before a view builder is registered. */ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { order: EMPTY_LIST, @@ -408,6 +413,8 @@ export const EMPTY_CHAT_SNAPSHOT: ChatSnapshot = { /** The immutable snapshot contract Session hands to uSES (see the web client architecture RFC). */ export interface ConversationSnapshot { sessionId: SessionId + /** Registered target snapshots assembled from Session events. */ + views: ConversationViewSnapshotStore /** Final Chat target assembled from independently registered business Definitions. */ chat: ChatSnapshot /** Legacy top-level compatibility field mirrored from the registered Chat Definitions. */ diff --git a/packages/client/runtime/src/client/sessions/history.ts b/packages/client/runtime/src/client/sessions/history.ts deleted file mode 100644 index 8609481d33..0000000000 --- a/packages/client/runtime/src/client/sessions/history.ts +++ /dev/null @@ -1,121 +0,0 @@ -import type { ToolSchema } from '@deepseek-ai/dsh-llm/types' -import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' -import type { - ConversationNode, PartialAssistant, RunningToolCall, -} from './conversation.ts' -import type { ConversationContext } from './conversation-context.ts' -import { projectConversationHistory } from '../session-history/history-fold.ts' -import { inspectRequests, type RequestView } from './request-inspection.ts' - -function assistantStepKey(turn: number, step: number): string { - return `${turn}\u0000${step}` -} - -function isFirstTokenCandidate(entry: HistoryEntry): boolean { - const event = entry.event - if (event.type !== 'assistant/chunk') return false - switch (event.data.chunk.type) { - case 'text-delta': - case 'reasoning-delta': - return event.data.chunk.text !== '' - case 'tool-call-delta': - return event.data.chunk.argumentsDelta !== '' || event.data.chunk.name !== undefined - default: - return false - } -} - -/** Lazily derived inspection data for one immutable session-history window. */ -export interface SessionHistoryInspection { - eventNodes: readonly ConversationNode[] - contexts: readonly ConversationContext[] - requests: readonly RequestView[] - callSchemas: ReadonlyMap - interruptedNodes: readonly ConversationNode[] - partial: PartialAssistant | null - runningCalls: readonly RunningToolCall[] -} - -/** - * Remove completed-step token payloads that no inspection projection reads. - * The first visible token preserves timing, usage chunks preserve accounting, - * and unfinished steps retain every chunk for live or interrupted content. - * @param entries - Contiguous raw history entries in sequence order. - * @returns A projection-equivalent, usually much smaller entry ledger. - */ -export function compactHistoryInspectionEntries( - entries: readonly HistoryEntry[], -): readonly HistoryEntry[] { - const completedSteps = new Set() - for (const { event } of entries) { - if (event.type === 'assistant/message') { - completedSteps.add(assistantStepKey(event.data.turn, event.data.step)) - } - } - - const firstTokenSteps = new Set() - const compacted: HistoryEntry[] = [] - let changed = false - for (const entry of entries) { - const event = entry.event - if (event.type !== 'assistant/chunk') { - compacted.push(entry) - continue - } - const key = assistantStepKey(event.data.turn, event.data.step) - if (!completedSteps.has(key) || event.data.chunk.type === 'usage') { - compacted.push(entry) - continue - } - if (isFirstTokenCandidate(entry) && !firstTokenSteps.has(key)) { - firstTokenSteps.add(key) - compacted.push(entry) - } else { - changed = true - } - } - return changed ? compacted : entries -} - -/** - * Create a lazy inspection projection over an immutable history window. - * Conversation consumers retain the cheap wrapper; only Trajectory snapshots - * the entries and replays event order and request lifecycle state. - * @param loadEntries - Lazily snapshots contiguous raw entries in sequence order. - * @returns Lazy, memoized inspection fields for that exact window. - */ -export function createHistoryInspection( - loadEntries: () => readonly HistoryEntry[], -): SessionHistoryInspection { - let entries: readonly HistoryEntry[] | undefined - let conversation: ReturnType | undefined - let requests: ReturnType | undefined - const historyEntries = () => entries ??= loadEntries() - const conversationProjection = () => - conversation ??= projectConversationHistory(historyEntries()) - const requestProjection = () => - requests ??= inspectRequests(historyEntries()) - return { - get eventNodes() { - return conversationProjection().eventNodes - }, - get contexts() { - return conversationProjection().contexts - }, - get interruptedNodes() { - return conversationProjection().interruptedNodes - }, - get partial() { - return conversationProjection().partial - }, - get runningCalls() { - return conversationProjection().runningCalls - }, - get requests() { - return requestProjection().requests - }, - get callSchemas() { - return requestProjection().callSchemas - }, - } -} diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index fd601090bf..741b3d36b2 100644 --- a/packages/client/runtime/src/client/sessions/manager.ts +++ b/packages/client/runtime/src/client/sessions/manager.ts @@ -4,7 +4,7 @@ import type { IApiClient, HostFrame, MuxFrame, RpcError, RpcRequest, RpcResult, SessionId, - SessionSummary, SubagentAddress, SubagentCatalog, WorkspaceId, + SessionSummary, SubagentAddress, SubagentCatalog, TaskView, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. @@ -48,6 +48,8 @@ export interface SessionListSnapshot { phase: SessionListPhase error: RpcError | null subagentsByParent: Readonly> + /** Background tasks per session; an absent key is an empty set. */ + tasksBySession: Readonly> currentAddress: SubagentAddress | undefined } @@ -138,6 +140,11 @@ export class SessionManager { private readonly catalogStale = new Set() private readonly openCatalogs = new Set() private readonly catalogDebounce = new Map>() + /** + * Background tasks per session, last-wins from `session/tasks`. An empty set + * is stored as an absent key, so absence and `[]` are one representation. + */ + private readonly tasksBySession = new Map() private selected: SessionId | undefined @@ -682,10 +689,23 @@ export class SessionManager { this.notifier.markDirty() return } + if (frame.type === 'session/tasks') { + // Whole-set snapshot, so last-wins with no reconciliation. The Host omits + // the baseline for an empty set, which is the same fact an emptying change + // reports as `[]` — both land as an absent key. + if (frame.tasks.length === 0) this.tasksBySession.delete(frame.sessionId) + else this.tasksBySession.set(frame.sessionId, frame.tasks) + this.notifier.markDirty() + return + } if (frame.type === 'session/subscribed') { // Rows past the host's durable baseline rode state a restart lost; drop // them so last-wins cannot pin a phantom value over recomputed truth. this.projectionStores.get(frame.sessionId)?.truncate(frame.lastSeq) + // Same re-baseline reasoning as the queue below: this generation sends a + // task baseline only when the set is non-empty, so a mirror kept from the + // previous generation would survive as a phantom list. + this.tasksBySession.delete(frame.sessionId) this.notifier.markDirty() // New mux-generation baseline: discard the previous queue snapshot. // The host omits session/queue when the live queue is empty, so retaining @@ -804,6 +824,11 @@ export class SessionManager { } this.pendingBuffers.delete(frame.sessionId) // a removed session's buffered frames must not replay on a future instantiation this.pendingInteractions.delete(frame.sessionId) // a removed session cannot wait on anyone + // Owner disposal already dropped these registry-side, but that lands on + // the mux stream while this frame rides the host stream, so the two have + // no relative order. Clearing here makes a detached Activation's rows + // disappear whichever arrives first. + this.tasksBySession.delete(frame.sessionId) if (!durableSubagent) this.projectionStores.delete(frame.sessionId) // A pull already in flight was requested before this removal and can // carry the pre-removal parentAvailable:true, which would resurrect @@ -1040,6 +1065,7 @@ export class SessionManager { phase: this.listPhase, error: this.listError, subagentsByParent: Object.fromEntries(this.catalogs), + tasksBySession: Object.fromEntries(this.tasksBySession), currentAddress: current === undefined ? undefined : this.addresses.get(current), } } diff --git a/packages/client/runtime/src/client/sessions/request-inspection.ts b/packages/client/runtime/src/client/sessions/request-inspection.ts index 162f34d5ff..9856bce2bc 100644 --- a/packages/client/runtime/src/client/sessions/request-inspection.ts +++ b/packages/client/runtime/src/client/sessions/request-inspection.ts @@ -1,17 +1,7 @@ -// Request-centric inspection read model. Ordinary generation and compaction -// calls share one chronological projection; presentation-specific grouping -// remains in the trajectory consumer. - -import type { ContentBlock, TokenUsage, ToolSchema } from '@deepseek-ai/dsh-llm/types' -import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type {} from '@deepseek-ai/dsh-compact/types' -import type {} from '@deepseek-ai/dsh-llm-retry/types' -import type {} from '@deepseek-ai/dsh-tools/types' +import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm/types' import type { AssistantProvenanceView, AssistantRequestConfig, } from './conversation.ts' -import { displayFailureMessage } from './failure-display.ts' export type { AssistantProvenanceView, AssistantRequestConfig, @@ -54,7 +44,7 @@ interface RequestViewBase { resultSeq?: number } -/** One ordinary assistant generation reconstructed from durable request events. */ +/** One ordinary assistant generation assembled from durable request events. */ interface AssistantRequestView extends RequestViewBase { purpose: 'assistant' turn: number @@ -85,321 +75,11 @@ interface CompactionRequestView extends RequestViewBase { rawOutput?: readonly ContentBlock[] } -/** One provider request reconstructed from durable request lifecycle events. */ +/** One provider request assembled from durable request lifecycle events. */ export type RequestView = AssistantRequestView | CompactionRequestView -/** Immutable request-centric projection derived from one history window. */ +/** Request data consumed by the stage-oriented Trajectory layout. */ export interface RequestInspectionSnapshot { requests: readonly RequestView[] callSchemas: ReadonlyMap } - -/** - * Derive the request-centric read model from one immutable history window. - * Compaction participates as a request purpose rather than a parallel - * top-level collection. A leading resume/change header exposes its prompt but - * cannot project a change until the preceding header enters the window. - * @param entries - Contiguous raw session history. - * @returns Requests and call-time schemas derived from that history. - */ -export function inspectRequests( - entries: readonly HistoryEntry[], -): RequestInspectionSnapshot { - const events = entries.map(entry => entry.event) - return { - requests: deriveRequests(events), - callSchemas: deriveCallSchemas(events), - } -} - -function requestKey(turn: number, step: number): string { - return `${turn}\u0000${step}` -} - -function addTokenUsage(current: unknown, next: TokenUsage): TokenUsage { - const previous = current as TokenUsage | undefined - return { - inputTokens: (previous?.inputTokens ?? 0) + next.inputTokens, - outputTokens: (previous?.outputTokens ?? 0) + next.outputTokens, - ...(previous?.cacheReadTokens === undefined && next.cacheReadTokens === undefined - ? {} - : { - cacheReadTokens: - (previous?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0), - }), - ...(previous?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined - ? {} - : { - cacheWriteTokens: - (previous?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0), - }), - ...(previous?.reasoningTokens === undefined && next.reasoningTokens === undefined - ? {} - : { - reasoningTokens: - (previous?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0), - }), - } -} - -function deriveCallSchemas( - events: readonly SessionEvent[], -): ReadonlyMap { - let active = new Map() - const calls = new Map() - const capture = (callId: string, name: string): void => { - if (calls.has(callId)) return - const schema = active.get(name) - if (schema !== undefined) calls.set(callId, schema) - } - for (const event of events) { - if (event.type === 'request/header') { - const tools: unknown = event.data.header.tools - active = new Map( - Array.isArray(tools) - ? (tools as ToolSchema[]).map(schema => [schema.name, schema]) - : [], - ) - continue - } - if (event.type === 'tool/call') { - capture(String(event.data.callId), event.data.name) - continue - } - if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') { - capture(String(event.data.subCallId), event.data.name) - } - } - return calls -} - -function promptChange( - previous: ConversationPromptSnapshot | undefined, - prompt: ConversationPromptSnapshot, - event: SessionEvent<'request/header'>, -): RequestPromptChange | undefined { - if (previous === undefined && event.data.reason !== 'initial') return - const systemChanged = previous !== undefined && previous.system !== prompt.system - const toolsChanged = previous !== undefined - && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools) - if (previous !== undefined && !systemChanged && !toolsChanged) return - return { - seq: event.seq, - time: event.time, - kind: previous === undefined - ? 'initial' - : systemChanged && toolsChanged - ? 'system-and-tools' - : systemChanged - ? 'system' - : 'tools', - ...(previous === undefined ? {} : { previous }), - } -} - -/** Project ordinary and compaction provider calls into one chronological request stream. */ -function deriveRequests(events: readonly SessionEvent[]): readonly RequestView[] { - const requests: RequestView[] = [] - const ordinaryByStep = new Map() - const lastStepByTurn = new Map() - let activeStep: string | undefined - let activePrompt: ConversationPromptSnapshot | undefined - let activeCompaction: number | undefined - - const updateAssistant = ( - index: number | undefined, - change: Partial>, - ): void => { - if (index === undefined) return - const request = requests[index] - if (request?.purpose === 'assistant') requests[index] = { ...request, ...change } - } - const updateCompaction = ( - index: number | undefined, - change: Partial>, - ): void => { - if (index === undefined) return - const request = requests[index] - if (request?.purpose === 'compaction') requests[index] = { ...request, ...change } - } - - for (const sourceEvent of events) { - if (sourceEvent.type === 'step/start') { - const { turn, step } = sourceEvent.data - const key = requestKey(turn, step) - ordinaryByStep.set(key, requests.length) - lastStepByTurn.set(turn, key) - requests.push({ - purpose: 'assistant', - startSeq: sourceEvent.seq, - turn, - step, - startedAt: sourceEvent.time, - completedAt: null, - status: 'running', - ...(activePrompt === undefined - ? {} - : { prompt: activePrompt, requestConfig: activePrompt.config }), - }) - activeStep = key - continue - } - if (sourceEvent.type === 'request/header') { - const tools: unknown = sourceEvent.data.header.tools - const prompt: ConversationPromptSnapshot = { - config: sourceEvent.data.header.config, - system: sourceEvent.data.header.system ?? '', - tools: Array.isArray(tools) ? tools as ToolSchema[] : [], - } - const change = promptChange(activePrompt, prompt, sourceEvent) - activePrompt = prompt - updateAssistant(activeStep === undefined ? undefined : ordinaryByStep.get(activeStep), { - prompt, - requestConfig: prompt.config, - ...(change === undefined ? {} : { promptChange: change }), - }) - continue - } - if ( - sourceEvent.type === 'assistant/chunk' - && sourceEvent.data.chunk.type === 'usage' - ) { - const index = ordinaryByStep.get( - requestKey(sourceEvent.data.turn, sourceEvent.data.step), - ) - const request = index === undefined ? undefined : requests[index] - updateAssistant(index, { - usage: addTokenUsage( - request?.purpose === 'assistant' ? request.usage : undefined, - sourceEvent.data.chunk.usage, - ), - }) - continue - } - if (sourceEvent.type === 'assistant/message') { - const index = ordinaryByStep.get( - requestKey(sourceEvent.data.turn, sourceEvent.data.step), - ) - const request = index === undefined ? undefined : requests[index] - updateAssistant(index, { - completedAt: sourceEvent.time, - status: 'complete', - resultSeq: sourceEvent.seq, - provenance: { - provider: sourceEvent.data.message.source.provider, - model: sourceEvent.data.message.source.model, - }, - ...(request?.purpose === 'assistant' - && request.usage !== undefined - || sourceEvent.data.usage === undefined - ? {} - : { usage: sourceEvent.data.usage }), - }) - continue - } - if (sourceEvent.type === 'step/end') { - const key = requestKey(sourceEvent.data.turn, sourceEvent.data.step) - const index = ordinaryByStep.get(key) - const request = index === undefined ? undefined : requests[index] - if (request?.purpose === 'assistant' && request.status === 'running') { - updateAssistant(index, { - completedAt: sourceEvent.time, - status: 'error', - }) - } - if (activeStep === key) activeStep = undefined - continue - } - if (sourceEvent.type === 'llm/retry') { - const data = sourceEvent.data - updateAssistant(ordinaryByStep.get(requestKey(data.turn, data.step)), { - status: 'error', - error: displayFailureMessage(data.failure), - retry: data.retry, - ...data.mode === 'normal' ? { maxRetries: data.maxRetries } : {}, - retryDelayMs: data.delayMs, - }) - continue - } - if (sourceEvent.type === 'turn/end') { - const lastStep = lastStepByTurn.get(sourceEvent.data.turn) - if (sourceEvent.data.reason.kind === 'error') { - updateAssistant(lastStep === undefined ? undefined : ordinaryByStep.get(lastStep), { - status: 'error', - error: displayFailureMessage(sourceEvent.data.reason.error), - }) - } - lastStepByTurn.delete(sourceEvent.data.turn) - continue - } - - if (sourceEvent.type === 'session/end-seed' && activeCompaction !== undefined) { - updateCompaction(activeCompaction, { - completedAt: sourceEvent.time, - status: 'error', - error: 'Compaction was interrupted before completion.', - }) - activeCompaction = undefined - continue - } - if (sourceEvent.type === 'compact/start') { - activeCompaction = requests.length - requests.push({ - purpose: 'compaction', - startSeq: sourceEvent.seq, - turn: sourceEvent.data.turn, - step: 0, - startedAt: sourceEvent.time, - completedAt: null, - status: 'running', - }) - continue - } - if (sourceEvent.type === 'compact/summary' && activeCompaction !== undefined) { - const data = sourceEvent.data - updateCompaction(activeCompaction, { - resultSeq: sourceEvent.seq, - summary: data.summary, - ...(data.rawOutput === undefined ? {} : { rawOutput: data.rawOutput }), - provenance: { - provider: data.provider, - model: data.model, - }, - requestConfig: { - provider: data.provider, - model: data.model, - purpose: 'compaction', - ...(data.maxTokens === undefined ? {} : { maxTokens: data.maxTokens }), - }, - ...(data.usage === undefined ? {} : { usage: data.usage }), - }) - continue - } - if ( - sourceEvent.type === 'user/message' - && activeCompaction !== undefined - && isCompactionSource(sourceEvent.data.source) - ) { - updateCompaction(activeCompaction, { replacementSeq: sourceEvent.seq }) - continue - } - if (sourceEvent.type !== 'compact/end' || activeCompaction === undefined) continue - updateCompaction(activeCompaction, { - completedAt: sourceEvent.time, - status: sourceEvent.data.error === undefined ? 'complete' : 'error', - ...(sourceEvent.data.error === undefined ? {} : { error: sourceEvent.data.error }), - }) - activeCompaction = undefined - } - - return requests.sort((left, right) => left.startSeq - right.startSeq) -} - -function isCompactionSource(source: unknown): boolean { - return typeof source === 'object' - && source !== null - && 'kind' in source - && source.kind === 'plugin' - && 'plugin' in source - && source.plugin === 'compact' -} diff --git a/packages/client/runtime/src/client/sessions/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 72edcea1c3..2b7267402e 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -14,9 +14,9 @@ * tears its scope down immediately unless it is the staged one, whose scope * survives frozen (read-only view) until the stage moves on. */ -import type { Context, Fiber } from 'cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import type { - IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, WorkspaceId, + IApiClient, RpcError, RpcResult, SessionId, SubagentAddress, TaskView, WorkspaceId, } from '@deepseek-ai/dsh-client-connection/client' // Value import from the inline-safe wire layer (not the connection plugin): // plugin-to-plugin value imports are a bundle purity error. @@ -86,6 +86,12 @@ export interface SessionListState { phase: SessionListPhase /** Direct durable catalogs keyed by their selected parent address. */ subagentsByParent: Readonly> + /** + * Background tasks each session can see, mirrored last-wins from + * `session/tasks`. A missing key is an empty set — the Host sends no baseline + * for a session without tasks — so consumers read absence, never a sentinel. + */ + tasksBySession: Readonly> /** Current session's catalog-derived address, absent on ordinary navigation. */ currentAddress: SubagentAddress | undefined } @@ -291,7 +297,7 @@ export class SessionsService implements ISessions { ) this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'pending', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) // The manager owns wire truth; the store is its projection. Manager // notifications are already microtask-batched. @@ -649,7 +655,7 @@ export class SessionsService implements ISessions { /** Project the manager's list snapshot into the store (title derivation is display-only). */ private projectList(): void { const { - items, current, phase, subagentsByParent, currentAddress, + items, current, phase, subagentsByParent, tasksBySession, currentAddress, } = this.manager.getListSnapshot() const ids: SessionId[] = [] const byId: Record = {} @@ -719,7 +725,7 @@ export class SessionsService implements ISessions { ...(currentAddress === undefined ? {} : { subagentAddress: currentAddress }), }) } - this.list.set({ ids, byId, current, phase, subagentsByParent, currentAddress }) + this.list.set({ ids, byId, current, phase, subagentsByParent, tasksBySession, currentAddress }) this.pruneScopes() } diff --git a/packages/client/runtime/src/client/sessions/session.ts b/packages/client/runtime/src/client/sessions/session.ts index b073bc7ef0..8e984d3edc 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -1,6 +1,6 @@ // Sessions remain resident after creation so they continue consuming mux frames off-screen. -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { AttachmentIdType, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' import type { SessionEvent } from '@deepseek-ai/dsh-session/types' import type { @@ -727,6 +727,7 @@ export class Session implements SessionFace { const legacy = chat.legacy return { sessionId: this.sessionId, + views: this.conversation, chat, nodes: legacy.nodes, turnTimings: legacy.turnTimings, diff --git a/packages/client/runtime/src/client/settings-scope.ts b/packages/client/runtime/src/client/settings-scope.ts index 91b6c7ec3a..cb7e933406 100644 --- a/packages/client/runtime/src/client/settings-scope.ts +++ b/packages/client/runtime/src/client/settings-scope.ts @@ -1,6 +1,6 @@ /** Host-backed settings-namespace synchronization for browser plugins. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, IApiClient, SettingsNamespaceView, } from '@deepseek-ai/dsh-client-connection/client' diff --git a/packages/client/runtime/src/client/slots.ts b/packages/client/runtime/src/client/slots.ts index e3ad848e05..e9b587bdc2 100644 --- a/packages/client/runtime/src/client/slots.ts +++ b/packages/client/runtime/src/client/slots.ts @@ -14,8 +14,8 @@ * holds this package's 'root' row in this compilation unit, but consumers * merge keys in; the rule fires on the narrow-map view, not on real * redundancy. */ -import { Service } from 'cordis' -import type { Context } from 'cordis' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import { SlotCore } from '@deepseek-ai/dsh-client-ui-slots' import type { LocaleFace, OwnerOf, SlotEntryDef, SlotMap, SlotRenderer, SlotRendererHost, diff --git a/packages/client/runtime/src/client/workspaces/service.ts b/packages/client/runtime/src/client/workspaces/service.ts index c0f71b92db..468ae95a19 100644 --- a/packages/client/runtime/src/client/workspaces/service.ts +++ b/packages/client/runtime/src/client/workspaces/service.ts @@ -1,6 +1,6 @@ /** WorkspacesService projects the Workspace object manager for UI consumers. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { DirectoryListing, IApiClient, RpcError, SessionId, WorkspaceId, WorkspaceView, diff --git a/packages/client/runtime/src/invariant.ts b/packages/client/runtime/src/invariant.ts index 2b055ede0b..c11a014cd9 100644 --- a/packages/client/runtime/src/invariant.ts +++ b/packages/client/runtime/src/invariant.ts @@ -8,7 +8,7 @@ * `keyof SlotMap & string` is the declare-merge key pattern: SlotMap is empty * in this compilation unit (intersection reads `never`) but consumers merge * keys in; the rule fires on the empty-map view, not on real redundancy. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SlotMap } from '@deepseek-ai/dsh-client-ui-slots' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/runtime/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 58d48affce..7c2bdf9bc6 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -3,7 +3,7 @@ * connection handle, stream-loop sink wiring into the object layer, and the * fiber-scoped loop teardown. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { ConnectionHandle } from '@deepseek-ai/dsh-client-connection/client' import type { ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' @@ -126,6 +126,7 @@ describe('runtime client apply', () => { const rebuild = vi.spyOn(Session.prototype, 'rebuildConversationRegistry') const definition: ConversationNodeDefinition = { kind: 'registry-probe', + target: 'chat', match: () => null, start: () => null, update: context => context.state, diff --git a/packages/client/runtime/tests/conversation-assembler.spec.ts b/packages/client/runtime/tests/conversation-assembler.spec.ts index 06dfd42567..6108169192 100644 --- a/packages/client/runtime/tests/conversation-assembler.spec.ts +++ b/packages/client/runtime/tests/conversation-assembler.spec.ts @@ -30,10 +30,16 @@ interface TestSnapshot { } class TestEventDefinitions { + readonly definitions: readonly ConversationNodeDefinition[] + readonly fallback: ConversationNodeDefinition | undefined + constructor( - readonly definitions: readonly ConversationNodeDefinition[], - readonly fallback?: ConversationNodeDefinition, - ) {} + definitions: readonly ConversationNodeDefinition[], + fallback?: ConversationNodeDefinition, + ) { + this.definitions = definitions + this.fallback = fallback + } entries(): readonly ConversationNodeDefinition[] { return this.definitions @@ -93,7 +99,10 @@ function chatSnapshot(assembler: ConversationNodeAssembler): TestSnapshot | unde return assembler.snapshot('chat') as TestSnapshot | undefined } -function node(context: Parameters[0], data: unknown): ConversationViewNode { +function node( + context: Parameters>[0], + data: unknown, +): ConversationViewNode { return { key: context.key, kind: context.kind, @@ -103,6 +112,17 @@ function node(context: Parameters[0 } } +function fallbackDefinition(start: () => string): ConversationNodeDefinition { + return { + kind: 'fallback', + target: 'chat', + match: event => ({ id: String(event.seq), role: 'start' }), + start, + update: context => context.state, + buildViewNode: context => node(context, context.state), + } +} + describe('ConversationNodeAssembler', () => { it('appends through an exact business-id Context without replaying unrelated Contexts', () => { const starts = vi.fn(( @@ -122,6 +142,7 @@ describe('ConversationNodeAssembler', () => { }, start: starts, update: updates, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -173,6 +194,7 @@ describe('ConversationNodeAssembler', () => { matchCollections.add(context.matches) return updates(context) }, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -208,6 +230,7 @@ describe('ConversationNodeAssembler', () => { }, start: starts, update: updates, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -247,6 +270,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => ({ settled: false }), update: updates, + target: 'chat', buildViewNode: context => node(context, context.state ?? { pendingStart: true }), } const assembler = new ConversationNodeAssembler( @@ -280,6 +304,7 @@ describe('ConversationNodeAssembler', () => { : event.type === 'turn/start' ? { id: 'one', role: 'update' } : null, start: () => null, update: context => context.state, + target: 'chat', buildViewNode: () => null, } const assembler = new ConversationNodeAssembler( @@ -301,6 +326,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, match) => Number((match.event.data as { value?: unknown }).value ?? 0), update: context => context.state, + target: 'chat', buildViewNode: () => null, } const consumerStart = vi.fn(( @@ -315,6 +341,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -344,6 +371,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, match) => match.event.seq, update: context => context.state, + target: 'chat', buildViewNode: () => null, } const consumer: ConversationNodeDefinition = { @@ -353,6 +381,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, _match, reader) => reader.previous('source')?.state ?? -1, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -397,6 +426,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -425,6 +455,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => 1, update: (_context, match) => (match.event.data as unknown as { value: number }).value, + target: 'chat', buildViewNode: () => null, } const consumerStart = vi.fn(( @@ -439,6 +470,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -468,6 +500,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => 1, update: (_context, match) => (match.event.data as unknown as { value: number }).value, + target: 'chat', buildViewNode: () => null, } const sourceX: ConversationNodeDefinition = { @@ -479,6 +512,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => 10, update: (_context, match) => (match.event.data as unknown as { value: number }).value, + target: 'chat', buildViewNode: () => null, } const middle: ConversationNodeDefinition = { @@ -491,6 +525,7 @@ describe('ConversationNodeAssembler', () => { + (reader.previous('diamond-x')?.state ?? 0) ), update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const consumer: ConversationNodeDefinition = { @@ -503,6 +538,7 @@ describe('ConversationNodeAssembler', () => { + (reader.previous('diamond-b')?.state ?? 0) ), update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -538,6 +574,7 @@ describe('ConversationNodeAssembler', () => { : null, start: starts, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -609,6 +646,7 @@ describe('ConversationNodeAssembler', () => { value: { valueSeenFromStep: stepValue ?? -1 }, } }, + target: 'chat', buildViewNode: (context) => { const location = context.start?.location if (location?.kind !== 'step') return null @@ -646,6 +684,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.start?.location.kind === 'turn' ? context.start.location.turn.steps.length : -1), @@ -701,6 +740,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => null, update: context => context.state, + target: 'chat', buildViewNode: (context) => { const location = context.start?.location const data = location?.kind === 'step' @@ -734,6 +774,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.start?.location.kind), } const assembler = new ConversationNodeAssembler( @@ -760,6 +801,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, + target: 'chat', buildViewNode: (context) => { const location = context.start?.location return node(context, location?.kind === 'step' @@ -792,6 +834,7 @@ describe('ConversationNodeAssembler', () => { : null, start: () => null, update: context => context.state, + target: 'chat', buildViewNode: (context) => { const location = context.start?.location return node(context, location?.kind === 'step' @@ -826,6 +869,7 @@ describe('ConversationNodeAssembler', () => { : null, start: seen, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -841,24 +885,64 @@ describe('ConversationNodeAssembler', () => { expect(seen).toHaveBeenCalledTimes(2) }) - it('does not invoke the fallback when an ordinary non-rendering Definition claims an event', () => { + it('invokes the fallback when only a State-only Definition claims an event', () => { + const fallbackStart = vi.fn(() => 'fallback') + const claimed: ConversationNodeDefinition = { + kind: 'claimed-state', + match: event => (event.type as string) === 'command/run' + ? { id: 'claimed', role: 'start' } + : null, + start: () => null, + update: context => context.state, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)), + new TestViewDefinitions([testView()]), + ) + + assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.flush() + + expect(fallbackStart).toHaveBeenCalledOnce() + expect(chatSnapshot(assembler)?.order).toHaveLength(1) + }) + + it('invokes the fallback when only another target claims an event', () => { + const fallbackStart = vi.fn(() => 'fallback') + const claimed: ConversationNodeDefinition = { + kind: 'claimed-trajectory', + target: 'trajectory', + match: event => (event.type as string) === 'command/run' + ? { id: 'claimed', role: 'start' } + : null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + const assembler = new ConversationNodeAssembler( + new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)), + new TestViewDefinitions([testView()]), + ) + + assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) + assembler.flush() + + expect(fallbackStart).toHaveBeenCalledOnce() + expect(chatSnapshot(assembler)?.order).toHaveLength(1) + }) + + it('suppresses the fallback when the same target claims an event', () => { const fallbackStart = vi.fn(() => 'fallback') const claimed: ConversationNodeDefinition = { kind: 'claimed', + target: 'chat', match: event => (event.type as string) === 'command/run' ? { id: 'claimed', role: 'start' } : null, start: () => null, update: context => context.state, buildViewNode: () => null, } - const fallback: ConversationNodeDefinition = { - kind: 'fallback', - match: event => ({ id: String(event.seq), role: 'start' }), - start: fallbackStart, - update: context => context.state, - buildViewNode: context => node(context, context.state), - } const assembler = new ConversationNodeAssembler( - new TestEventDefinitions([claimed], fallback), + new TestEventDefinitions([claimed], fallbackDefinition(fallbackStart)), new TestViewDefinitions([testView()]), ) assembler.replaceWindow([input(at(1, 'command/run', { commandId: 'one', name: 'x' }))], false) @@ -878,6 +962,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => true, update: () => false, + target: 'chat', buildViewNode: context => context.state === true ? node(context, true) : null, } const assembler = new ConversationNodeAssembler( @@ -900,6 +985,7 @@ describe('ConversationNodeAssembler', () => { match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, start: () => undefined, update: context => context.state, + target: 'chat', buildViewNode: () => null, } const startAssembler = new ConversationNodeAssembler( @@ -919,6 +1005,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => true, update: () => undefined as never, + target: 'chat', buildViewNode: context => node(context, context.state), } const updateAssembler = new ConversationNodeAssembler( @@ -939,6 +1026,7 @@ describe('ConversationNodeAssembler', () => { match: event => (event.type as string) === 'command/run' ? { id: 'one', role: 'start' } : null, start: (_context, match) => match.event.seq, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( diff --git a/packages/client/runtime/tests/conversation-registry.spec.ts b/packages/client/runtime/tests/conversation-registry.spec.ts index 19cbdf17f9..5181dab926 100644 --- a/packages/client/runtime/tests/conversation-registry.spec.ts +++ b/packages/client/runtime/tests/conversation-registry.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { ConversationEventRegistry } from '../src/client/conversation/event-registry.ts' @@ -13,6 +13,7 @@ import { FakeApiClient, ok } from './fake-api.ts' function eventDefinition(kind: string): ConversationNodeDefinition { return { kind, + target: 'chat', match: () => null, start: () => null, update: context => context.state, @@ -71,6 +72,40 @@ describe('Conversation registries', () => { expect(events.fallbackEntry()).toBeUndefined() }) + it('rejects rendering Definitions that omit either target or builder', async () => { + const { events } = await bootRegistries() + const targetOnly: ConversationNodeDefinition = { + kind: 'target-only', + target: 'chat', + match: () => null, + start: () => null, + update: context => context.state, + } + const builderOnly: ConversationNodeDefinition = { + kind: 'builder-only', + match: () => null, + start: () => null, + update: context => context.state, + buildViewNode: () => null, + } + + expect(() => events.register(targetOnly)).toThrow(/target and buildViewNode together/) + expect(() => events.register(builderOnly)).toThrow(/target and buildViewNode together/) + }) + + it('rejects a State-only Definition as the unmatched-event fallback', async () => { + const { events } = await bootRegistries() + const fallback: ConversationNodeDefinition = { + kind: 'state-only-fallback', + match: () => null, + start: () => null, + update: context => context.state, + } + + expect(() => events.registerFallback(fallback)) + .toThrow('conversation fallback Definition must declare a target') + }) + it('rejects duplicate view targets and disposes a view registration once', async () => { const { views } = await bootRegistries() const definition = viewDefinition('chat') diff --git a/packages/client/runtime/tests/history-fold.spec.ts b/packages/client/runtime/tests/history-fold.spec.ts deleted file mode 100644 index 2f15bc9c92..0000000000 --- a/packages/client/runtime/tests/history-fold.spec.ts +++ /dev/null @@ -1,232 +0,0 @@ -import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { describe, expect, it } from 'vitest' -import { projectConversationHistory } from '../src/client/session-history/history-fold.ts' -import { compactHistoryInspectionEntries } from '../src/client/sessions/history.ts' -import { inspectRequests } from '../src/client/sessions/request-inspection.ts' -import { ev } from './event-script.ts' - -const at = (seq: number, event: Record): SessionEvent => - ({ seq, time: 1_700_000_000_000 + seq, ...event }) as unknown as SessionEvent - -describe('projectConversationHistory', () => { - it('names an injected context node from its durable source, like the live adapter', () => { - // The fold declares its own node mapping (jscpd:ignore in the source), so - // the source projection is pinned on both sides independently. - const injected = at(0, { - type: 'user/message', - surfaceOp: 'append', - data: createUserMessage({ - content: [{ type: 'text', text: '' }], - // A plugin source, because the client program does not see the host - // packages that merge richer source kinds; those arms are pinned in - // context-provenance.spec.ts. - source: { kind: 'plugin', plugin: 'dsh-tool-skill', form: 'catalog' }, - }), - }) - const { contexts } = projectConversationHistory([{ event: injected }]) - expect(contexts[contexts.length - 1]?.nodes).toMatchObject([{ - kind: 'context', - seq: 0, - provenance: { role: 'inject', label: 'dsh-tool-skill' }, - form: 'catalog', - }]) - }) - - it('projects next-step human input as durable steering', () => { - const steering = createUserMessage({ - content: [{ type: 'text', text: 'change course' }], - source: { kind: 'user' }, - }) - const events = [ - at(0, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, inserted: [steering], - } }), - at(1, { type: 'agent/inbox/spliced', data: { - target: 'next-step', start: 0, removedCount: 1, inserted: [], - } }), - at(2, { type: 'user/message', surfaceOp: 'append', data: steering }), - ] - const projection = projectConversationHistory(events.map(event => ({ event }))) - expect(projection.eventNodes).toMatchObject([{ - kind: 'steering', messageId: steering.id, seq: 2, - }]) - }) - - it('projects a high-sequence history window without synthesizing its unloaded prefix', () => { - const baseSeq = 400_000 - const events = [ - ev.user(baseSeq, 'loaded tail'), - at(baseSeq + 1, { - type: 'assistant/message', - surfaceOp: { op: 'replace', start: baseSeq, end: baseSeq }, - sourceEventSeqs: [baseSeq], - data: { - turn: 80, - step: 1, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'tail summary' }], - source: { kind: 'model', provider: 'fake', model: 'fake' }, - }), - }, - }), - ] - - const projection = projectConversationHistory(events.map(event => ({ event }))) - expect(projection.eventNodes.map(node => node.seq)).toEqual([baseSeq, baseSeq + 1]) - expect(projection.contexts.map(context => ({ - originSeq: context.originSeq, - nodes: context.nodes.map(node => node.seq), - }))).toEqual([ - { originSeq: undefined, nodes: [baseSeq] }, - { originSeq: baseSeq + 1, nodes: [baseSeq + 1] }, - ]) - }) - - it('projects frozen surface generations without widening the core live surface', () => { - const events = [ - ev.user(0, 'a'), - ev.user(1, 'b'), - at(2, { - type: 'assistant/message', - surfaceOp: { op: 'replace', start: 0, end: 0 }, - sourceEventSeqs: [0], - data: { - turn: 1, - step: 1, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'summary' }], - source: { kind: 'model', provider: 'fake', model: 'fake' }, - }), - }, - }), - at(3, { - type: 'assistant/message', - surfaceOp: { op: 'replace', start: 2, end: 1 }, - sourceEventSeqs: [2, 1], - data: { - turn: 1, - step: 2, - message: createMessage({ - role: 'assistant', - content: [{ type: 'text', text: 'summary 2' }], - source: { kind: 'model', provider: 'fake', model: 'fake' }, - }), - }, - }), - ] - - expect(projectConversationHistory(events.map(event => ({ event }))).contexts.map(context => ({ - id: context.id, - parentId: context.parentId, - originSeq: context.originSeq, - nodes: context.nodes.map(node => node.seq), - }))).toEqual([ - { id: 0, parentId: undefined, originSeq: undefined, nodes: [0, 1] }, - { id: 1, parentId: 0, originSeq: 2, nodes: [2, 1] }, - { id: 2, parentId: 1, originSeq: 3, nodes: [3] }, - ]) - }) - - it('projects assistant timing and the active request header from history', () => { - const projection = projectConversationHistory([ - ev.stepStart(0, 1, 2), - at(1, { type: 'request/header', data: { - reason: 'initial', - header: { - config: { provider: 'fake', model: 'first' }, - tools: [], - }, - } }), - ev.chunkStart(2, 1, 2), - ev.chunkText(3, 1, 'token', 2), - ev.assistant(4, 1, 'done', 2), - ev.stepStart(5, 2, 1), - ev.chunkText(6, 2, 'next', 1), - ev.assistant(7, 2, 'next done', 1), - ].map(event => ({ event }))) - - expect(projection.eventNodes[0]).toMatchObject({ - kind: 'assistant', - timing: { - stepStartTime: 1_700_000_000_000, - firstTokenTime: 1_700_000_000_003, - completedTime: 1_700_000_000_004, - }, - requestConfig: { provider: 'fake', model: 'first' }, - }) - - expect(projection.eventNodes.at(-1)).toMatchObject({ - timing: { - stepStartTime: 1_700_000_000_005, - firstTokenTime: 1_700_000_000_006, - completedTime: 1_700_000_000_007, - }, - requestConfig: { provider: 'fake', model: 'first' }, - }) - }) - - it('projects nested dispatches onto settled and interrupted history calls', () => { - const projection = projectConversationHistory([ - ev.turnStart(0, 1), - ev.toolCall(1, 1, 'settled', 'run_code', '{}'), - ev.codeDispatchStart(2, 'settled', 1, 'run_code', { code: 'nested' }), - ev.codeDispatchStart(3, 'settled:code:1', 1, 'read', { path: 'a.txt' }), - ev.codeDispatch(4, 'settled:code:1', 1, 'read', { path: 'a.txt' }, 'alpha'), - ev.codeDispatch(5, 'settled', 1, 'run_code', { code: 'nested' }, 'alpha'), - ev.toolResult(6, 1, 'settled', 'done'), - ev.turnEnd(7, 1), - ev.turnStart(8, 2), - ev.toolCall(9, 2, 'interrupted', 'run_code', '{}'), - ev.codeDispatchStart(10, 'interrupted', 1, 'bash', { command: 'sleep 1' }), - ev.turnEnd(11, 2, 'aborted'), - ].map(event => ({ event }))) - - const settled = { - callId: 'settled', - subCalls: [{ - callId: 'settled:code:1', - subCalls: [{ callId: 'settled:code:1:code:1', call: { name: 'read' } }], - }], - } - expect(projection.eventNodes).toMatchObject([settled]) - expect(projection.contexts[0]?.nodes).toMatchObject([settled]) - expect(projection.interruptedNodes).toMatchObject([{ - callId: 'interrupted', - subCalls: [{ callId: 'interrupted:code:1', name: 'bash' }], - }]) - }) - - it('drops completed token payloads without changing inspection projections', () => { - const events = [ - ev.user(0, 'before'), - ev.stepStart(1, 1, 0), - ev.chunkStart(2, 1), - ev.chunkText(3, 1, ''), - ev.chunkText(4, 1, 'first'), - ev.chunkText(5, 1, ' discarded'), - at(6, { type: 'assistant/chunk', data: { - turn: 1, - step: 0, - chunk: { type: 'usage', usage: { inputTokens: 4, outputTokens: 2 } }, - } }), - ev.assistant(7, 1, 'first discarded'), - ev.compactSummary(8, 'summary', 0, 7), - ev.compactCheckpoint(9, 8, 0, 7), - ev.stepStart(10, 2, 0), - ev.chunkStart(11, 2), - ev.chunkText(12, 2, 'interrupted'), - ev.turnEnd(13, 2, 'aborted'), - ] - const raw = events.map(event => ({ event })) - const compacted = compactHistoryInspectionEntries(raw) - - expect(compacted.map(entry => entry.event.seq)).toEqual([ - 0, 1, 4, 6, 7, 8, 9, 10, 11, 12, 13, - ]) - expect(projectConversationHistory(compacted)).toEqual(projectConversationHistory(raw)) - expect(inspectRequests(compacted)).toEqual(inspectRequests(raw)) - }) -}) diff --git a/packages/client/runtime/tests/invariant.spec.ts b/packages/client/runtime/tests/invariant.spec.ts index 708b714b2f..9e869baa44 100644 --- a/packages/client/runtime/tests/invariant.spec.ts +++ b/packages/client/runtime/tests/invariant.spec.ts @@ -3,7 +3,7 @@ * a fired key must already carry a bumped version (emission follows the * applied mutation), bogus payloads fail loud, foreign events pass. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import InvariantService from '@deepseek-ai/dsh-invariants' import * as RuntimeInvariant from '../src/invariant.ts' diff --git a/packages/client/runtime/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index c69465df45..7eba55fd64 100644 --- a/packages/client/runtime/tests/manager.spec.ts +++ b/packages/client/runtime/tests/manager.spec.ts @@ -1110,3 +1110,60 @@ describe('completed reminder', () => { expect(entry(manager, S2)?.completed).toBe(true) }) }) + +describe('background-task mirror', () => { + const view = (over: Partial<{ id: string; status: string; label: string }> = {}) => ({ + id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5, ...over, + }) + const tasksFrame = (sessionId: SessionId, tasks: unknown[]) => + ({ rpcId: 't' as never, payload: { type: 'session/tasks', sessionId, tasks } as never }) + + it('mirrors the whole set last-wins, keyed per session, with no Session instance needed', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + manager.handleMuxEnvelope(tasksFrame(S2, [view({ id: 'pwsh-1', label: 'other' })])) + const first = manager.getListSnapshot().tasksBySession + expect(first[S1]).toEqual([view()]) + expect(first[S2]?.[0]?.label).toBe('other') + + // Last-wins: the newer whole set replaces, it does not merge. + manager.handleMuxEnvelope(tasksFrame(S1, [view({ status: 'completed' })])) + expect(manager.getListSnapshot().tasksBySession[S1]).toEqual([view({ status: 'completed' })]) + }) + + it('stores an emptied set as an absent key so absence and [] read alike', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + expect(S1 in manager.getListSnapshot().tasksBySession).toBe(true) + manager.handleMuxEnvelope(tasksFrame(S1, [])) + expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false) + }) + + it('clears the mirror on re-subscribe, because a task-free generation sends no baseline', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + manager.handleMuxEnvelope({ + rpcId: 's' as never, + payload: { type: 'session/subscribed', sessionId: S1, lastSeq: 3 }, + }) + expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false) + }) + + it('drops the rows when the session is removed, whichever stream lands first', () => { + const manager = new SessionManager(new FakeApiClient()) + manager.handleHostEnvelope({ rpcId: 'a' as never, payload: { type: 'host/session-added', blank: true, sessionId: S1 } }) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + manager.handleHostEnvelope({ rpcId: 'r' as never, payload: { type: 'host/session-removed', sessionId: S1 } }) + expect(S1 in manager.getListSnapshot().tasksBySession).toBe(false) + }) + + it('notifies list subscribers so an open header re-renders without a poll', async () => { + const manager = new SessionManager(new FakeApiClient()) + const seen = vi.fn() + manager.subscribe(seen) + manager.handleMuxEnvelope(tasksFrame(S1, [view()])) + // The notifier batches on a microtask; the frame itself is already applied. + await Promise.resolve() + expect(seen).toHaveBeenCalled() + }) +}) diff --git a/packages/client/runtime/tests/request-inspection.spec.ts b/packages/client/runtime/tests/request-inspection.spec.ts deleted file mode 100644 index 031c2f4b45..0000000000 --- a/packages/client/runtime/tests/request-inspection.spec.ts +++ /dev/null @@ -1,319 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { HistoryEntry } from '@deepseek-ai/dsh-client-connection/client' -import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import { inspectRequests } from '../src/client/sessions/request-inspection.ts' - -const at = (seq: number, type: string, data: unknown): SessionEvent => - ({ seq, time: 1_700_000_000_000 + seq, type, data }) as SessionEvent - -const entriesOf = (events: readonly SessionEvent[]): HistoryEntry[] => - events.map(event => ({ event })) - -describe('inspectRequests', () => { - it('projects ordinary and compaction calls into one chronological request stream', () => { - const events = [ - at(0, 'step/start', { turn: 1, step: 1 }), - at(1, 'request/header', { - reason: 'initial', - header: { - config: { provider: 'fake', model: 'model' }, - system: 'system', - tools: [{ - name: 'read', - description: 'Read a file.', - parameters: { type: 'object' }, - }], - }, - }), - at(2, 'tool/call', { - turn: 1, - step: 1, - callId: 'call-1', - name: 'read', - arguments: '{}', - }), - at(3, 'assistant/message', { - turn: 1, - step: 1, - message: createAssistantMessage({ - content: [{ type: 'text', text: 'done' }], - source: { provider: 'fake', model: 'model' }, - }), - usage: { inputTokens: 5, outputTokens: 2 }, - }), - at(4, 'step/end', { turn: 1, step: 1 }), - at(5, 'compact/start', { turn: 1 }), - at(6, 'compact/summary', { - summary: [{ type: 'text', text: 'summary' }], - rawOutput: [ - { type: 'reasoning', text: 'thought' }, - { type: 'text', text: 'summary' }, - ], - provider: 'fake', - model: 'compact-model', - usage: { inputTokens: 8, outputTokens: 3 }, - }), - at(7, 'user/message', createUserMessage({ - content: [{ type: 'text', text: 'checkpoint' }], - source: { kind: 'plugin', plugin: 'compact' }, - })), - at(8, 'compact/end', { turn: 1 }), - ] - const snapshot = inspectRequests(entriesOf(events)) - expect(snapshot.requests).toMatchObject([ - { - purpose: 'assistant', - startSeq: 0, - resultSeq: 3, - status: 'complete', - prompt: { - config: { provider: 'fake', model: 'model' }, - system: 'system', - }, - promptChange: { seq: 1, kind: 'initial' }, - }, - { - purpose: 'compaction', - startSeq: 5, - resultSeq: 6, - replacementSeq: 7, - status: 'complete', - summary: [{ type: 'text', text: 'summary' }], - }, - ]) - expect(snapshot.callSchemas.get('call-1')?.name).toBe('read') - }) - - it('does not promote a truncated resume or change header to the initial prompt', () => { - for (const reason of ['resume', 'change'] as const) { - const snapshot = inspectRequests(entriesOf([ - at(10, 'step/start', { turn: 3, step: 1 }), - at(11, 'request/header', { - reason, - header: { - config: { provider: 'fake', model: 'model' }, - system: 'tail-window prompt', - }, - }), - ])) - - expect(snapshot.requests[0]).toMatchObject({ - purpose: 'assistant', - prompt: { system: 'tail-window prompt' }, - }) - expect(snapshot.requests[0]).not.toHaveProperty('promptChange') - } - }) - - it('classifies a prompt change once the preceding header is loaded', () => { - const snapshot = inspectRequests(entriesOf([ - at(0, 'step/start', { turn: 1, step: 1 }), - at(1, 'request/header', { - reason: 'initial', - header: { - config: { provider: 'fake', model: 'model' }, - system: 'before', - }, - }), - at(2, 'step/start', { turn: 1, step: 2 }), - at(3, 'request/header', { - reason: 'change', - header: { - config: { provider: 'fake', model: 'model' }, - system: 'after', - }, - }), - ])) - - expect(snapshot.requests[1]).toMatchObject({ - promptChange: { - seq: 3, - kind: 'system', - previous: { system: 'before' }, - }, - }) - }) - - it('preserves a standalone compaction owner without widening assistant turns', () => { - const snapshot = inspectRequests(entriesOf([ - at(0, 'compact/start', { turn: null }), - at(1, 'compact/summary', { - summary: [{ type: 'text', text: 'standalone summary' }], - provider: 'fake', - model: 'compact-model', - }), - at(2, 'compact/end', { turn: null }), - at(3, 'step/start', { turn: 2, step: 1 }), - ])) - - const [compaction, assistant] = snapshot.requests - expect(compaction).toMatchObject({ - purpose: 'compaction', - turn: null, - step: 0, - status: 'complete', - }) - expect(assistant).toMatchObject({ - purpose: 'assistant', - turn: 2, - step: 1, - status: 'running', - }) - if (assistant?.purpose === 'assistant') { - const turn: number = assistant.turn - expect(turn).toBe(2) - } - }) - - it('interrupts an orphaned compaction at end-seed before projecting a new attempt', () => { - const snapshot = inspectRequests(entriesOf([ - at(0, 'compact/start', { turn: null }), - at(1, 'session/end-seed', {}), - at(2, 'compact/start', { turn: null }), - at(3, 'compact/summary', { - summary: [{ type: 'text', text: 'replacement summary' }], - provider: 'fake', - model: 'compact-model', - }), - at(4, 'compact/end', { turn: null }), - ])) - - expect(snapshot.requests).toMatchObject([ - { - purpose: 'compaction', - startSeq: 0, - status: 'error', - completedAt: 1_700_000_000_001, - error: 'Compaction was interrupted before completion.', - }, - { - purpose: 'compaction', - startSeq: 2, - status: 'complete', - completedAt: 1_700_000_000_004, - summary: [{ type: 'text', text: 'replacement summary' }], - }, - ]) - }) - - it('captures schemas for nested tool dispatches from the active request header', () => { - const snapshot = inspectRequests(entriesOf([ - at(0, 'request/header', { - reason: 'initial', - header: { - config: { provider: 'fake', model: 'model' }, - tools: [{ - name: 'read', - description: 'Read a file.', - parameters: { type: 'object' }, - }], - }, - }), - at(1, 'tool/code-dispatch-start', { - parentCallId: 'parent', - subCallId: 'nested', - name: 'read', - arguments: {}, - }), - ])) - - expect(snapshot.callSchemas.get('nested')?.name).toBe('read') - }) - - it('keeps chunk-reported usage through request failure and prefers it to message fallback', () => { - const chunkUsage = { inputTokens: 21, outputTokens: 3 } - const retryUsage = { - inputTokens: 5, - outputTokens: 2, - cacheReadTokens: 8, - reasoningTokens: 1, - } - const snapshot = inspectRequests(entriesOf([ - at(0, 'step/start', { turn: 1, step: 1 }), - at(1, 'assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'usage', usage: chunkUsage }, - }), - at(2, 'llm/retry', { - turn: 1, - step: 1, - retry: 1, - maxRetries: 2, - delayMs: 100, - failure: { message: 'rate limited' }, - }), - at(3, 'assistant/chunk', { - turn: 1, - step: 1, - chunk: { type: 'usage', usage: retryUsage }, - }), - at(4, 'assistant/message', { - turn: 1, - step: 1, - message: createAssistantMessage({ - content: [{ type: 'text', text: 'recovered' }], - source: { provider: 'fake', model: 'model' }, - }), - usage: { inputTokens: 1, outputTokens: 1 }, - }), - ])) - - expect(snapshot.requests[0]).toMatchObject({ - status: 'complete', - usage: { - inputTokens: 26, - outputTokens: 5, - cacheReadTokens: 8, - reasoningTokens: 1, - }, - }) - }) - - it('keeps provider credential fragments out of projected request errors', () => { - const snapshot = inspectRequests(entriesOf([ - at(0, 'step/start', { turn: 1, step: 1 }), - at(1, 'turn/end', { - turn: 1, reason: { kind: 'error', error: { - code: 'AUTH', - message: 'Authentication Fails, Your api key: sk-preview-secret is invalid', - }, - }, - }), - at(2, 'step/start', { turn: 2, step: 1 }), - at(3, 'turn/end', { - turn: 2, reason: { kind: 'error', error: { message: 'plugin exploded', code: 'UNKNOWN' } }, - }), - ])) - - expect(snapshot.requests).toMatchObject([ - { status: 'error', error: 'API key is invalid' }, - { status: 'error', error: 'plugin exploded' }, - ]) - }) - - it('treats a scrubbed durable-fixture tool catalog as unavailable', () => { - const snapshot = inspectRequests(entriesOf([ - at(0, 'step/start', { turn: 1, step: 1 }), - at(1, 'request/header', { - reason: 'initial', - header: { - config: { provider: 'fake', model: 'model' }, - tools: '{{tools}}', - }, - }), - at(2, 'tool/call', { - turn: 1, - step: 1, - callId: 'call-1', - name: 'read', - arguments: '{}', - }), - ])) - - expect(snapshot.callSchemas).toEqual(new Map()) - const [request] = snapshot.requests - expect(request?.purpose === 'assistant' ? request.prompt?.tools : undefined).toEqual([]) - }) -}) diff --git a/packages/client/runtime/tests/scope.spec.ts b/packages/client/runtime/tests/scope.spec.ts index f1c3847ce2..4c69b46edc 100644 --- a/packages/client/runtime/tests/scope.spec.ts +++ b/packages/client/runtime/tests/scope.spec.ts @@ -6,14 +6,14 @@ * and a subject-less root dispatch stays unfiltered. Scope-owned listeners * dispose with the fiber. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { createScope, scopeOf } from '../src/client/agents/scope.ts' const sid = (k: string): SessionId => k as SessionId -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { /** * Test-only routed probe event. diff --git a/packages/client/runtime/tests/session-history-source.spec.ts b/packages/client/runtime/tests/session-history-source.spec.ts deleted file mode 100644 index 2bc0aa87af..0000000000 --- a/packages/client/runtime/tests/session-history-source.spec.ts +++ /dev/null @@ -1,180 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest' -import type { SessionEvent } from '@deepseek-ai/dsh-session/types' -import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' -import { SessionHistorySource } from '../src/client/session-history/source.ts' -import { FakeApiClient, deferred, err, ok } from './fake-api.ts' -import { entries, ev, plainTurn } from './event-script.ts' - -const SID = 'history-s1' as SessionId - -afterEach(() => { - vi.unstubAllGlobals() -}) - -function histResponse(events: SessionEvent[], hasMore = false) { - return Promise.resolve(ok({ events: entries(events) as never[], hasMore })) -} - -describe('SessionHistorySource', () => { - it('loads the tail first and prepends older pages on demand', async () => { - const pages = [ - plainTurn(0, 0, '最早问', '最早答'), - plainTurn(6, 1, '中间问', '中间答'), - plainTurn(12, 2, '最新问', '最新答'), - ] - const api = new FakeApiClient() - api.onHistory = (payload) => { - if (payload.beforeSeq === undefined) return histResponse(pages[2]!, true) - if (payload.beforeSeq === 12) return histResponse(pages[1]!, true) - return histResponse(pages[0]!, false) - } - const source = new SessionHistorySource(SID, api) - - await source.loadTail() - - expect(api.callsOf('session.history')).toHaveLength(1) - expect(source.getSnapshot().hasMore).toBe(true) - expect(source.getSnapshot().baseSeq).toBe(12) - expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) - .toEqual([13, 15]) - - expect(await source.loadOlder()).toBe(true) - expect(await source.loadOlder()).toBe(true) - expect(await source.loadOlder()).toBe(false) - - expect(api.callsOf('session.history')).toHaveLength(3) - expect(source.getSnapshot().hasMore).toBe(false) - expect(source.getSnapshot().baseSeq).toBe(0) - expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) - .toEqual([1, 3, 7, 9, 13, 15]) - }) - - it('pins a lazy inspection to the entries in its source snapshot', async () => { - const api = new FakeApiClient() - api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) - const source = new SessionHistorySource(SID, api) - await source.loadTail() - const before = source.getSnapshot() - - source.handleMuxFrame({ - type: 'session/event', - sessionId: SID, - event: ev.user(6, 'later'), - }) - - expect(before.inspection.eventNodes.map(node => node.seq)).toEqual([1, 3]) - expect(source.getSnapshot().inspection.eventNodes.map(node => node.seq)) - .toEqual([1, 3, 6]) - }) - - it('publishes multiple assistant chunks once per browser frame', async () => { - const api = new FakeApiClient() - api.onHistory = () => histResponse(plainTurn(0, 0, '问', '答')) - const source = new SessionHistorySource(SID, api) - await source.loadTail() - const frames: FrameRequestCallback[] = [] - vi.stubGlobal('requestAnimationFrame', (callback: FrameRequestCallback) => { - frames.push(callback) - return frames.length - }) - let notifications = 0 - const unsubscribe = source.subscribe(() => { notifications++ }) - const before = source.getSnapshot().inspection - const finalizedNodes = before.eventNodes - const requests = before.requests - const contexts = before.contexts - - for (const event of [ - ev.chunkStart(6, 1), - ev.chunkText(7, 1, 'stream '), - ev.chunkText(8, 1, 'content'), - ]) { - source.handleMuxFrame({ - type: 'session/event', - sessionId: SID, - event, - }) - } - - expect(frames).toHaveLength(1) - expect(notifications).toBe(0) - frames[0]?.(0) - await Promise.resolve() - - expect(notifications).toBe(1) - const streamed = source.getSnapshot().inspection - expect(streamed.eventNodes).toBe(finalizedNodes) - expect(streamed.requests).toBe(requests) - expect(streamed.contexts).toBe(contexts) - expect(streamed.partial?.blocks).toEqual([ - { kind: 'text', text: 'stream content' }, - ]) - - source.handleMuxFrame({ - type: 'session/event', - sessionId: SID, - event: ev.chunkText(9, 1, ' then final'), - }) - source.handleMuxFrame({ - type: 'session/event', - sessionId: SID, - event: ev.assistant(10, 1, 'stream content then final'), - }) - await Promise.resolve() - - expect(notifications).toBe(2) - const finalized = source.getSnapshot().inspection - expect(finalized.eventNodes).not.toBe(finalizedNodes) - expect(finalized.partial).toBeNull() - frames[1]?.(0) - await Promise.resolve() - expect(notifications).toBe(2) - unsubscribe() - }) - - it('stops loading when an older page fails to advance', async () => { - const api = new FakeApiClient() - api.onHistory = payload => payload.beforeSeq === undefined - ? histResponse(plainTurn(6, 1, '新问', '新答'), true) - : Promise.resolve(err({ - code: 'internal', - message: 'page unavailable', - details: {}, - })) - const source = new SessionHistorySource(SID, api) - - await source.loadTail() - expect(await source.loadOlder()).toBe(false) - - expect(api.callsOf('session.history')).toHaveLength(2) - expect(source.getSnapshot().hasMore).toBe(true) - }) - - it('finishes an already started older page after consumer cancellation', async () => { - const middle = deferred>>() - const olderStarted = deferred() - const api = new FakeApiClient() - api.onHistory = (payload) => { - if (payload.beforeSeq === undefined) { - return histResponse(plainTurn(12, 2, '最新问', '最新答'), true) - } - olderStarted.resolve(undefined) - return middle.promise - } - const source = new SessionHistorySource(SID, api) - const controller = new AbortController() - await source.loadTail(controller.signal) - const complete = source.loadOlder(controller.signal) - await olderStarted.promise - controller.abort() - middle.resolve(ok({ - events: entries(plainTurn(6, 1, '中间问', '中间答')) as never[], - hasMore: true, - })) - - expect(await complete).toBe(true) - - expect(api.callsOf('session.history')).toHaveLength(2) - expect(source.getSnapshot().hasMore).toBe(true) - }) -}) diff --git a/packages/client/runtime/tests/session.spec.ts b/packages/client/runtime/tests/session.spec.ts index 36d9ce1b3d..0795d9a849 100644 --- a/packages/client/runtime/tests/session.spec.ts +++ b/packages/client/runtime/tests/session.spec.ts @@ -123,12 +123,13 @@ function testViewDefinition(): ConversationViewDefinition = { kind: 'runtime-test-event', + target: 'chat', match: event => ({ id: String(event.seq), role: 'start' }), start: (_context, match) => ({ event: match.event, view: match.view }), update: context => context.state, publication: match => match.event.type === 'assistant/chunk' ? 'animation-frame' : 'immediate', - buildViewNode: (context, target) => { - if (target !== 'chat' || context.state === undefined || context.start === undefined) return null + buildViewNode: (context) => { + if (context.state === undefined || context.start === undefined) return null return { key: context.key, kind: 'runtime-test-event', diff --git a/packages/client/runtime/tests/sessions-service.spec.ts b/packages/client/runtime/tests/sessions-service.spec.ts index 3b25ff849c..c80e7e78c1 100644 --- a/packages/client/runtime/tests/sessions-service.spec.ts +++ b/packages/client/runtime/tests/sessions-service.spec.ts @@ -6,7 +6,7 @@ * deferral — the stage follows list.current), binding identity, breadcrumb * projection, create. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-connection/client' import { SessionCreateError, SessionsService, scopeOf } from '../src/client/sessions/service.ts' diff --git a/packages/client/runtime/tests/settings-scope.spec.ts b/packages/client/runtime/tests/settings-scope.spec.ts index db980bf6d1..ae3c1db73c 100644 --- a/packages/client/runtime/tests/settings-scope.spec.ts +++ b/packages/client/runtime/tests/settings-scope.spec.ts @@ -1,5 +1,5 @@ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { describe, expect, it, vi } from 'vitest' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' import { diff --git a/packages/client/runtime/tests/slots-service.spec.ts b/packages/client/runtime/tests/slots-service.spec.ts index a796d324ee..7869500f7e 100644 --- a/packages/client/runtime/tests/slots-service.spec.ts +++ b/packages/client/runtime/tests/slots-service.spec.ts @@ -5,7 +5,7 @@ * contract (double install / not installed / non-root key), store instance * resolution and lifecycle on the ledger axis, and the entry-unload cascade. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { FC } from 'react' import type { SlotRendererHost } from '@deepseek-ai/dsh-client-ui-slots' diff --git a/packages/client/runtime/tests/wire-events.spec.ts b/packages/client/runtime/tests/wire-events.spec.ts index e82c4cae3b..cc1f6c374c 100644 --- a/packages/client/runtime/tests/wire-events.spec.ts +++ b/packages/client/runtime/tests/wire-events.spec.ts @@ -4,7 +4,7 @@ * ctx 'session/preset-changed'; each established connection generation → * ctx 'connection/reset' (the forced cache-invalidation broadcast). */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { ConnectionHandle, ConnectionSinks } from '@deepseek-ai/dsh-client-connection/client' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' diff --git a/packages/client/runtime/tests/workspaces-service.spec.ts b/packages/client/runtime/tests/workspaces-service.spec.ts index dd38a3119c..aa0f404da6 100644 --- a/packages/client/runtime/tests/workspaces-service.spec.ts +++ b/packages/client/runtime/tests/workspaces-service.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { SessionId, WorkspaceId, WorkspaceView } from '@deepseek-ai/dsh-client-connection/client' import { SessionsService } from '../src/client/sessions/service.ts' diff --git a/packages/client/schema-form/package.json b/packages/client/schema-form/package.json index c1cd5e8018..90f59b329a 100644 --- a/packages/client/schema-form/package.json +++ b/packages/client/schema-form/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-schema-form", "description": "Schema/draft model layer for settings editors: rehydrates a serialized schemastery schema, validates drafts, and edits them immutably by path", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/schema-form" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -20,15 +27,15 @@ }, "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/schema-form/src/invariant.ts b/packages/client/schema-form/src/invariant.ts index f60f951fb5..90636e5d67 100644 --- a/packages/client/schema-form/src/invariant.ts +++ b/packages/client/schema-form/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-schema-form' diff --git a/packages/client/schema-form/src/model.ts b/packages/client/schema-form/src/model.ts index 5377012141..5cfb624eb4 100644 --- a/packages/client/schema-form/src/model.ts +++ b/packages/client/schema-form/src/model.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-client-schema-form/model */ -import Schema from 'schemastery' +import Schema from '@deepseek-ai/schemastery' /** Live schemastery node; the renderer reads only its structural relations. */ export type SchemaNode = Schema diff --git a/packages/client/schema-form/tests/invariant.spec.ts b/packages/client/schema-form/tests/invariant.spec.ts index 7f7ba10dd8..5507c23e66 100644 --- a/packages/client/schema-form/tests/invariant.spec.ts +++ b/packages/client/schema-form/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as SchemaFormInvariant from '@deepseek-ai/dsh-client-schema-form/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/schema-form/tests/model.spec.ts b/packages/client/schema-form/tests/model.spec.ts index 81e81e1992..1a95e88903 100644 --- a/packages/client/schema-form/tests/model.spec.ts +++ b/packages/client/schema-form/tests/model.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import Schema from 'schemastery' +import Schema from '@deepseek-ai/schemastery' import { deletePath, getPath, hasPath, nodeAtPath, rehydrateSchema, setPath, validateDraft, } from '../src/model.ts' diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index c93a302124..96f3add186 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-test-runtime", "description": "jsdom slot test runtime: real Cordis Context + SlotsService + web-react renderer with test-owned session/workspace doubles for feature specs", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/test-runtime" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,12 +32,12 @@ "vitest": "^4.1.8" }, "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-client-web-react": "^0.0.1", - "@deepseek-ai/dsh-host-apiproxy": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-host-apiproxy": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -42,7 +49,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/test-runtime/src/fixtures.ts b/packages/client/test-runtime/src/fixtures.ts index 7f65a0b5a3..3a44b05048 100644 --- a/packages/client/test-runtime/src/fixtures.ts +++ b/packages/client/test-runtime/src/fixtures.ts @@ -2,7 +2,9 @@ import type { ConversationSnapshot, ISession, SessionId, SessionSummary, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' -import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' +import { + EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' /** * Fixture overrides for the session behavior face: any subset of the @@ -46,6 +48,7 @@ export interface SessionFixture { export function conversationSnapshot(sessionId: SessionId): ConversationSnapshot { return { sessionId, + views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), diff --git a/packages/client/test-runtime/src/index.ts b/packages/client/test-runtime/src/index.ts index 5dea393047..e94030a1f1 100644 --- a/packages/client/test-runtime/src/index.ts +++ b/packages/client/test-runtime/src/index.ts @@ -14,8 +14,8 @@ * `keyof SlotMap & string` is the declare-merge key pattern (see ui-slots): * this compilation unit sees only the runtime's 'root' row, but consumer * programs merge their own keys in; the rule fires on the narrow-map view. */ -import { Context, Inject } from 'cordis' -import type { Fiber, Plugin } from 'cordis' +import { Context, Inject } from '@deepseek-ai/cordis' +import type { Fiber, Plugin } from '@deepseek-ai/cordis' import { createElement, Fragment, useSyncExternalStore } from 'react' import type { ReactNode } from 'react' import { act, render, within } from '@testing-library/react' diff --git a/packages/client/test-runtime/src/invariant.ts b/packages/client/test-runtime/src/invariant.ts index 09ef3da6ce..bb4ea6f6f1 100644 --- a/packages/client/test-runtime/src/invariant.ts +++ b/packages/client/test-runtime/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 059cf22ebc..fc41c83975 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -1,5 +1,5 @@ /** Test-owned sessions face: the SlotsService host contract over declarative fixtures. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { AttachmentIdType } from '@deepseek-ai/dsh-attachment' import { createScope, scopeOf, SessionProvideChannel } from '@deepseek-ai/dsh-client-runtime/client' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' @@ -202,7 +202,7 @@ export class TestSessions implements ISessions { constructor(private readonly stabilize: Stabilizer, private readonly rootCtx: Context) { this.list = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) this.channel = new SessionProvideChannel({ rebuildBundles: () => { diff --git a/packages/client/test-runtime/tests/invariant.spec.ts b/packages/client/test-runtime/tests/invariant.spec.ts index 837559ec81..08b7e5725c 100644 --- a/packages/client/test-runtime/tests/invariant.spec.ts +++ b/packages/client/test-runtime/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as TestRuntimeInvariant from '@deepseek-ai/dsh-client-test-runtime/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/tsdown.client.ts b/packages/client/tsdown.client.ts index d7252cd195..1b4ea86eea 100644 --- a/packages/client/tsdown.client.ts +++ b/packages/client/tsdown.client.ts @@ -32,6 +32,14 @@ const CSS_VIRTUAL_SUFFIX = '.mjs' */ export const INLINE_SAFE = /^@deepseek-ai\/dsh-(host-apiproxy|session|llm|tools|brand)(\/|$)/ +/** + * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below + * would read them as plugin packages. They carry no cross-plugin runtime + * identity to share — the framework itself is a platform module (external), + * while these are ordinary libraries a browser bundle inlines. + */ +const VENDORED_LIBRARY = /^@deepseek-ai\/(cosmokit|schemastery)(\/|$)/ + /** Generated descriptor/codec contribution with no shared runtime identity. */ const GENERATED_REMOTE = /^@deepseek-ai\/dsh-[a-z0-9]+(?:-[a-z0-9]+)*\/remote$/ @@ -208,6 +216,7 @@ function clientConfig(id: string, entry: string): UserConfig { resolveId(source: string) { if (!source.startsWith('@deepseek-ai/')) return null if (CLIENT_EXTERNALS.includes(source)) return null // platform module: external wins + if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point throw new Error( `client bundle purity: "${source}" is not a platform module (CLIENT_EXTERNALS), an inline-safe wire layer, or a generated /remote contribution — ` diff --git a/packages/client/ui-agent-preset/README.i18n.yaml b/packages/client/ui-agent-preset/README.i18n.yaml index ca91d9f97e..4f949bfa86 100644 --- a/packages/client/ui-agent-preset/README.i18n.yaml +++ b/packages/client/ui-agent-preset/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/client/ui-agent-preset/README.md -README.md: 008066114e9c49e5c74299979e24c27a4c9621c9 -README.zh.md: e07d5994ae196cd03be7818fe4ade1aafda9aa55 +README.md: 3b0db5a3eedca256a00b65a3bd2738f22c0eb62e +README.zh.md: 6f3c350f973119c201572f2c03145338b5cc5b00 diff --git a/packages/client/ui-agent-preset/README.md b/packages/client/ui-agent-preset/README.md index 008066114e..3b0db5a3ee 100644 --- a/packages/client/ui-agent-preset/README.md +++ b/packages/client/ui-agent-preset/README.md @@ -36,6 +36,8 @@ A fourth surface, its own settings page (`settings.section` id `agent-presets`, The browser edits no composition text. Editing YAML in a web textarea was a weak surface (no completion, no highlighting, no diff), so a new preset is a host-side copy of an existing one — the dialog collects an id (it becomes the directory name, which is why it must be named up front and cannot change later) and an optional display name, and `{ from, id, name? }` is all that crosses the wire. Everything else — description, composition, skills — is edited in the preset's own files, and the page's other job is getting the user TO those files: the copy completes by opening the new directory, and every custom row keeps a location action. Where the host has no desktop opener (`hasDocument: false` on the roster; remote and container deployments), the same actions answer the directory as text on the row instead of offering a button that would spawn into nothing. +A preset publishes its own description, of any length, and the grid sizes every card row alike — so an unbounded description would set the height of the whole roster. Cards clamp it to four lines and offer the rest in a tooltip, attached only while the text is actually cut off. The clamp is CSS, so the whole description stays in the accessibility tree whatever the card shows. + A shipped preset opens in the read-only viewer. It is the known-good composition a copy starts from, so reading it is the point; it offers no location and no delete — its install is overwritten by upgrades and is not the user's to manage. The intro carries the guidance a create button used to imply: duplicate an existing preset and make it yours, or let the agent draft one in Creator mode. Beside copying sits the conversational entry: when the roster carries the self-referential `cordis` preset, a dashed add-card (the Models page's affordance) stages it and starts a new session — the section closes the settings panel through the shell's owner-prop `close` and the new-session chip's own applier composes the blank session the workspace flow produces. The seat keeps a late roster load from regressing the display: staged pick first, then the composition the current session already carries, then the deployment default. @@ -44,7 +46,7 @@ The dialog mirrors the host's own containment rule (`[a-z0-9][a-z0-9-]*`) and re Deleting removes the preset directory. Sessions already composed from it keep running — a composition is mounted once at session creation and nothing re-reads the file. -A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a Broken badge, the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start. +A roster row carrying `broken` (the host's shape check found the composition missing or unloadable) renders as a marked card: red border, a "Failed to load" badge (what discovery observed, not a claim that the files are damaged — the usual cause is a composition the user just edited or deleted), the reason verbatim, the body disabled — it cannot become the default — and duplication disabled, since a copy of a broken preset is another broken preset. A broken custom row keeps its location and delete actions, because the files are where it gets fixed and deleting is how a ghost directory (composition deleted by hand, directory still blocking the id) is cleared; a broken shipped row withholds the viewer too — there is no readable composition to show. The two pickers (the General row and the new-session chip) drop broken presets entirely: they choose the NEXT session's composition, and offering one that cannot compose would only defer the failure to the session start. Setting the default writes the `agent-presets` settings namespace, which the host exposes to configuration clients ([`dsh-apiproxy`](../../host/apiproxy/README.md) keeps an explicit allowlist — a namespace outside it makes a picker move and then silently forget). diff --git a/packages/client/ui-agent-preset/README.zh.md b/packages/client/ui-agent-preset/README.zh.md index e07d5994ae..6f3c350f97 100644 --- a/packages/client/ui-agent-preset/README.zh.md +++ b/packages/client/ui-agent-preset/README.zh.md @@ -36,6 +36,8 @@ preset 文件提供一套未国际化的 `name` 与 `description`,Web 将其 浏览器不再编辑任何组装文本。在网页文本域里编 YAML 是弱功能(无补全、无高亮、无 diff),因此新 preset 是宿主端对既有 preset 的一次复制——对话框只收集一个 id(它将成为目录名,所以必须当场取好、事后无法更改)与一个可选显示名,跨越传输层的只有 `{ from, id, name? }`。其余一切——描述、组装、skills——都在 preset 自己的文件里编辑,而本页的另一职责正是把用户送到那些文件面前:复制以打开新目录作为收尾,每张自定义卡片也保有一个位置操作。宿主没有桌面打开器时(名单上的 `hasDocument: false`;远程与容器部署),同样的操作改为把目录以文本显示在卡片上,而不是提供一个点了没反应的按钮。 +preset 自行发布描述,长度不限,而网格让每一行卡片等高——因此不加约束的描述会决定整份名单的高度。卡片把描述截断为四行,其余内容由 tooltip 承载,且仅在文本确实被裁切时才挂载。截断由 CSS 完成,因此无论卡片显示多少,完整描述始终留在无障碍树中。 + 随附 preset 在只读查看器中打开。它是副本据以出发的已知良好组装,因此能读到它正是意义所在;它不提供位置也不提供删除——它的安装目录会被升级覆盖,不归用户管理。开篇引导语承担了从前创建按钮所暗示的信息:复制一份既有预设改成自己的,或用「创造模式」让 Agent 帮你创建。 复制旁边是对话式入口:名单携带自指的 `cordis` preset 时,一张虚线添加卡(模型页的同款样式)会暂存它并开启新会话——分区经外壳的 owner-prop `close` 关闭设置面板,新会话 chip 自己的应用器负责组装工作区流程产出的空白会话。seat 会防止晚到的名单加载回退显示:暂存选择优先,其次是当前会话已携带的组装,最后才是部署默认值。 @@ -44,7 +46,7 @@ preset 文件提供一套未国际化的 `name` 与 `description`,Web 将其 删除会移除整个 preset 目录。已据其组装的会话继续运行——组装在会话创建时挂载一次,此后没有任何东西会重新读取该文件。 -名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「已损坏」徽记、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。 +名单行携带 `broken`(宿主的形状检查发现组装缺失或不可加载)时渲染为标记卡片:红色边框、「加载失败」徽记(discovery 观察到的事实,而非断言文件已损坏——常见起因是用户刚编辑或删除了组装文件)、原样展示的原因、卡片主体禁用——它不能成为默认——复制也禁用,因为损坏 preset 的副本只是又一个损坏的 preset。损坏的自定义行保留位置与删除动作:文件正是修复它的地方,而删除正是清掉幽灵目录(组装文件被手动删除、目录仍占着 id)的方式;损坏的内置行连查看器也不提供——没有可读的组装可展示。两个选择器(通用设置行与新会话 chip)则完全不列出损坏的 preset:它们选的是下一个会话的组装,列出无法组装的选项只会把失败推迟到会话启动。 设置默认值写入的是 `agent-presets` settings 命名空间,宿主需将其暴露给配置客户端([`dsh-apiproxy`](../../host/apiproxy/README.md) 维护一份显式白名单——不在其中的命名空间会让选择器动一下然后悄悄忘记)。 diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 2f682d3de5..e5d4e27790 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-agent-preset", "description": "Agent-preset surfaces: the default for later sessions, this session's seat, and the composition editor", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-agent-preset" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -40,16 +47,16 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-settings": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-client-web-react": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -64,7 +71,7 @@ "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css index 5468f0d592..6d2cdd814b 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.module.css @@ -5,7 +5,7 @@ align-items: center; gap: 4px; max-width: 180px; - padding: 0 8px; + padding: 0 2px 0 0; height: 22px; border-radius: 6px; background: var(--dsw-alias-fill-tsp-secondary); diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 517a856e9a..3e98310cca 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -11,7 +11,7 @@ import { useEffect } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconThinkOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconAgentPresetOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the header actions). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSettingsState } from './settings-store.ts' @@ -57,7 +57,7 @@ export function AgentPresetLabel({ const text = option === undefined ? undefined : presetDisplayText(option, t) return ( - + {text?.name ?? preset} ) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index a4e4c50309..55fe22e81b 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -9,7 +9,7 @@ min-height: 28px; padding: 0 8px; border: none; - border-radius: 12px; + border-radius: 16px; background: transparent; color: var(--dsw-alias-label-primary); font-size: 13px; @@ -36,6 +36,61 @@ color: var(--dsw-alias-label-primary); } +/* Introduce cue: the icon eases in on an overshoot-free expo curve (duration + matches INTRO_TEXT_DELAY_MS, so the characters start the moment it lands), + then the name's characters fade up on a stagger (delays set inline per + character). All chars occupy their width from the start, so nothing + reflows mid-run. */ +.introIcon { + animation: seat-icon-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both; +} + +@keyframes seat-icon-in { + from { + opacity: 0; + transform: scale(0.5); + } + + to { + opacity: 1; + transform: scale(1); + } +} + +/* Wraps the staggered characters into one flex item, so the chip's gap + applies around the name as a whole rather than between characters. */ +.introText { + display: inline-block; + white-space: pre; +} + +.introChar { + display: inline-block; + white-space: pre; + opacity: 0; + animation: seat-char-in 0.4s ease-out forwards; +} + +@keyframes seat-char-in { + from { + opacity: 0; + transform: translateY(4px); + } + + to { + opacity: 1; + transform: none; + } +} + +@media (prefers-reduced-motion: reduce) { + .introIcon, + .introChar { + animation: none; + opacity: 1; + } +} + .chevron { flex: none; color: var(--dsw-alias-label-caption); diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index f4357870bb..f7350076c2 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -15,7 +15,7 @@ import { useEffect, useState } from 'react' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' -import { IconChevronDownOutline14, IconThinkOutline16, Menu } from '@deepseek-ai/dsh-client-ui-primitives' +import { IconAgentPresetOutline16, IconChevronDownOutline14, Menu } from '@deepseek-ai/dsh-client-ui-primitives' // Type-only: pulls the ui-conversation SlotMap merge (the hero seat). import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import type { AgentPresetSeatState } from './seat-store.ts' @@ -32,6 +32,29 @@ export interface AgentPresetSeatInjected { load: () => Promise /** Stage one preset for the next session. */ select: (id: string) => Promise + /** Clear the one-shot introduce cue once the chip has played it. */ + introduced: () => void +} + +/* Introduce timeline: the icon eases in first (the CSS animation shares this + duration); the name's characters start fading up the moment it lands, each + taking the fade duration to settle. The cue clears after the last one. The + stagger is capped twice: per tick for short CJK names, and by one shared + reveal window so a long Latin name finishes in the same time as its CJK + counterpart instead of dragging the run out per character. */ +const INTRO_TEXT_DELAY_MS = 150 +const INTRO_CHAR_STAGGER_MS = 40 +const INTRO_TEXT_REVEAL_MS = 200 +const INTRO_CHAR_FADE_MS = 400 + +/** + * Per-character start offset for the introduce reveal. + * @param count - character count of the shown preset name. + * @returns milliseconds between successive character starts. + */ +function introStaggerMs(count: number): number { + if (count <= 1) return 0 + return Math.min(INTRO_CHAR_STAGGER_MS, INTRO_TEXT_REVEAL_MS / (count - 1)) } /** Full component props. */ @@ -45,7 +68,7 @@ export type AgentPresetSeatProps = * @param props - composed slot props. * @returns the chip, or null when the deployment composes no presets. */ -export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPresetSeatProps) { +export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, t }: AgentPresetSeatProps) { const state = useAgentPresetSeat(snapshot => snapshot) const [open, setOpen] = useState(false) @@ -53,12 +76,54 @@ export function AgentPresetSeat({ load, select, useAgentPresetSeat, t }: AgentPr void load() }, [load]) - // Nothing to choose between: the deployment composes no presets and every - // session shares the host composition. - if (state.options.length === 0 || state.current === '') return null - const chosen = state.options.find(option => option.id === state.current) const chosenText = chosen === undefined ? undefined : presetDisplayText(chosen, t) + const label = chosenText?.name ?? state.current + const ready = state.options.length > 0 && state.current !== '' + + // The introduce cue: the pick was staged from another screen (the settings + // creator entry), so the chip announces it — the icon eases in and each + // character of the name fades up on a stagger (CSS owns the motion; this + // effect only arms it and acknowledges the cue once the run is over). + const [introducing, setIntroducing] = useState(false) + useEffect(() => { + if (!state.introduce || !ready) return + const characters = Array.from(label) + if (characters.length === 0 || window.matchMedia('(prefers-reduced-motion: reduce)').matches) { + introduced() + return + } + setIntroducing(true) + const done = window.setTimeout(() => { + setIntroducing(false) + introduced() + }, INTRO_TEXT_DELAY_MS + (characters.length - 1) * introStaggerMs(characters.length) + INTRO_CHAR_FADE_MS) + return () => { window.clearTimeout(done) } + }, [state.introduce, ready, label, introduced]) + + // Nothing to choose between: the deployment composes no presets and every + // session shares the host composition. + if (!ready) return null + + // One wrapper span: the chip is a flex row with a gap, so loose character + // spans would each pick up the gap between them. + const characters = Array.from(label) + const stagger = introStaggerMs(characters.length) + const shownLabel = introducing + ? ( + + {characters.map((character, index) => ( + + {character} + + ))} + + ) + : label return ( { setOpen(value => !value) }} > - - {chosenText?.name ?? state.current} + + {shownLabel} )} diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index f29bf7cdf5..0a8d2fa8a3 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -26,6 +26,12 @@ gap: 10px; } +/* Group-to-group breathing room: the section's 12px gap plus 20px reads the + two rosters as separate blocks (32px total). */ +.group + .group { + margin-top: 20px; +} + .groupHead { margin: 0; font-size: 12px; @@ -155,15 +161,28 @@ color: var(--dsw-alias-bg-layer-3); } +/* Bounded to four lines. A preset publishes its own description, so one long + one would otherwise stretch every card in its grid row (`.cards` sizes rows + 1fr). Clamping is CSS alone: the whole text stays in the DOM for assistive + tech, and the card offers it on hover when it is actually cut off. The + description does not grow to fill the card — `-webkit-line-clamp` on a + flex-stretched box leaves the clamp height and the box height disagreeing, + so `.cardId` takes the free space with an auto margin instead. */ .cardDesc { font-size: 13px; line-height: 1.55; color: var(--dsw-alias-label-secondary); - flex: 1; min-height: 42px; + display: -webkit-box; + -webkit-box-orient: vertical; + -webkit-line-clamp: 4; + overflow: hidden; + /* A user-authored description may carry an unbreakable path or URL. */ + overflow-wrap: anywhere; } .cardId { + margin-top: auto; font-family: var(--dsw-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace); font-size: 11px; color: var(--dsw-alias-label-dimmed); @@ -363,6 +382,7 @@ create button vacated. Dashed like the Models page's add affordances: it reads as a place a preset will appear, not a command. */ .creatorButton { + box-sizing: border-box; align-self: stretch; display: flex; align-items: center; @@ -372,17 +392,18 @@ border: 1px dashed var(--dsw-alias-border-l3); border-radius: 12px; font: inherit; - font-size: 13px; - background: none; - color: inherit; + font-size: 14px; + line-height: 22px; + background: transparent; + color: var(--dsw-alias-label-primary); cursor: pointer; } .creatorButton:hover:not(:disabled) { - background: var(--dsw-alias-bg-layer-1); + background: var(--dsw-alias-interactive-bg-hover); } .creatorButton:disabled { - opacity: 0.5; + opacity: 0.4; cursor: default; } diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx index f5a31fcdf8..cac8452f00 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -10,10 +10,10 @@ * mounted once at session creation and nothing re-reads the file. */ -import { useEffect } from 'react' +import { useEffect, useLayoutEffect, useRef, useState } from 'react' import type { ReactNode } from 'react' import { - Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, + Button, IconBrowseOutline16, IconCopyOutline16, IconFolderOpenOutline16, IconPlusOutline16, IconTrashOutline16, Modal, Tooltip, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import type { InjectFace, PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' @@ -137,6 +137,39 @@ function CopyDialog({ state, t, actions }: CopyDialogProps): ReactNode { ) } +/** + * Render one card's description, clamped by CSS and offered in full on hover. + * The tooltip is attached only while the text is actually cut off, so a short + * description does not answer a hover with a bubble repeating the card. + * @param props.text - the description as rendered, already localized. + * @returns the description element, tooltip-anchored while it overflows. + */ +function CardDescription({ text }: { text: string }): ReactNode { + const ref = useRef(null) + const [truncated, setTruncated] = useState(false) + useLayoutEffect(() => { + const el = ref.current + /* v8 ignore next -- the ref is attached before layout effects run. */ + if (el === null) return + const measure = () => { setTruncated(el.scrollHeight > el.clientHeight) } + measure() + // Card width follows the settings pane, which resizes with the window. + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(measure) + observer.observe(el) + return () => { observer.disconnect() } + }, [text]) + return ( + // Capped near the card's own width: the default half-viewport bubble would + // spill a description out of the settings dialog and across the app behind it. + + {/* The empty title stops the card body's native tooltip from climbing to + this span: a cut-off description answers with one bubble, not two. */} + {text} + + ) +} + /** * Render the Agent presets section content column. * @param props - composed slot props. @@ -171,6 +204,30 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { ) } + /* The guided alternative to copying: the self-referential preset can + read this very composition and author a new one in conversation. + Offered only where that preset is actually on the roster and a + session can be landed; without a writable root the draft could + never be discovered, so the reason rides the disabled button. */ + const creatorButton = props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') + ? ( + + ) + : null + return (

{t('nav')}

@@ -180,147 +237,130 @@ export function AgentPresetSection(props: AgentPresetSectionProps): ReactNode { const group = state.rows .filter(row => row.trust === trust) .map(row => ({ row, text: presetDisplayText(row, t) })) - if (group.length === 0) return null + // The custom group is where a preset of one's own will appear, so it + // stays on screen even while empty: heading plus the creator entry. + const tail = trust === 'user' ? creatorButton : null + if (group.length === 0 && tail === null) return null return (

{heading}

-
    - {group.map(({ row, text }) => ( -
  • - {/* The card body IS the control: picking a preset is the + {group.length === 0 ? null : ( +
      + {group.map(({ row, text }) => ( +
    • + {/* The card body IS the control: picking a preset is the common act, so it should not hide behind a small button. The action row sits outside it — nesting buttons is invalid, and these act on the card rather than select it. A broken preset cannot compose a session, so its body is disabled and the card says why instead of offering it. */} - -
      - {/* Shipped presets are the compositions a copy starts + + {row.broken === undefined + ? null + : {row.broken}} + {row.id} + +
      + {/* Shipped presets are the compositions a copy starts from, so READING one is the point; a custom preset is edited in its files instead, which the location action leads to. A broken shipped preset has no readable composition to offer, so its viewer is withheld; a broken custom one keeps the location action — the files are where it gets fixed. */} - {row.trust === 'system' - ? row.broken === undefined - ? ( + {row.trust === 'system' + ? row.broken === undefined + ? ( + + ) + : null + : ( + )} + + {row.trust === 'user' + ? ( + ) - : null + : null} +
      + {state.revealedPaths[row.id] === undefined + ? null : ( - +

      + {t('revealedPathLabel')} + {state.revealedPaths[row.id]} +

      )} - - {row.trust === 'user' - ? ( - - ) - : null} -
      - {state.revealedPaths[row.id] === undefined - ? null - : ( -

      - {t('revealedPathLabel')} - {state.revealedPaths[row.id]} -

      - )} -
    • - ))} -
    +
  • + ))} +
+ )} + {tail}
) })} - {/* The guided alternative to copying: the self-referential preset can - read this very composition and author a new one in conversation. - Offered only where that preset is actually on the roster and a - session can be landed; without a writable root the draft could - never be discovered, so the reason rides the disabled button. */} - {props.startCreatorDraft !== undefined && state.rows.some(row => row.id === 'cordis') - ? ( - - ) - : null} seat.load(), select: (id: string) => seat.select(id), + introduced: () => { seat.introduced() }, }) const labelInjected = (): AgentPresetLabelInjected => ({ @@ -146,7 +147,9 @@ export function apply(ctx: ClientContext): void { // on: the chip's list-change applier composes the blank session the // workspace connect produces or reuses. creatorDraft = () => { - seat.stage('cordis') + // The introduce cue makes the chip announce the pick the user never + // made on this screen — the stage happened back in settings. + seat.stage('cordis', true) scope.workspaces.startSession() } const chip = scope.slots.register({ diff --git a/packages/client/ui-agent-preset/src/client/locales.ts b/packages/client/ui-agent-preset/src/client/locales.ts index 0fab8db94b..2a3dee06f3 100644 --- a/packages/client/ui-agent-preset/src/client/locales.ts +++ b/packages/client/ui-agent-preset/src/client/locales.ts @@ -42,7 +42,7 @@ export const en: Record = { 'All Standard mode capabilities, with tools exposed through the Code Mode SDK so the model can combine multi-step operations in one TypeScript program.', presetMinimalName: 'Minimal mode', presetMinimalDescription: - 'Two-tool coding agent with only bash and str_replace_editor, for benchmarks and minimal reproductions.', + 'Two-tool coding agent with persistent bash and str_replace_editor.', presetCordisName: 'Creator mode', presetCordisDescription: 'Built for creating custom agent presets, with all Standard mode capabilities plus runtime inspection, plugin experiments, and preset-authoring guidance.', @@ -57,8 +57,8 @@ export const en: Record = { builtInGroup: 'Built-in', customGroup: 'Custom', noDescription: 'No description.', - brokenBadge: 'Broken', - brokenNoCopy: 'Broken presets cannot be duplicated', + brokenBadge: 'Failed to load', + brokenNoCopy: 'A preset that failed to load cannot be duplicated', copyOf: 'Copied from', composition: 'Composition (agent.cordis.yml)', cancel: 'Cancel', @@ -103,7 +103,7 @@ export const zh: Record = { presetCodeName: '代码模式', presetCodeDescription: '具备标准模式的全部能力,并通过 Code Mode SDK 呈现工具,让模型用一个 TypeScript 程序组合多步操作。', presetMinimalName: '极简模式', - presetMinimalDescription: '仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。', + presetMinimalDescription: '仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。', presetCordisName: '创造模式', presetCordisDescription: '用于创建自定义 Agent preset:具备标准模式的全部能力,并提供运行时检查、插件实验和 preset 创作指导。', duplicate: '复制', @@ -117,8 +117,8 @@ export const zh: Record = { builtInGroup: '内置', customGroup: '自定义', noDescription: '暂无描述。', - brokenBadge: '已损坏', - brokenNoCopy: '预设已损坏,无法复制', + brokenBadge: '加载失败', + brokenNoCopy: '预设加载失败,不能复制', copyOf: '复制自', composition: '组装(agent.cordis.yml)', cancel: '取消', diff --git a/packages/client/ui-agent-preset/src/client/seat-store.ts b/packages/client/ui-agent-preset/src/client/seat-store.ts index 27a414e4a3..ab973ec5b5 100644 --- a/packages/client/ui-agent-preset/src/client/seat-store.ts +++ b/packages/client/ui-agent-preset/src/client/seat-store.ts @@ -26,10 +26,16 @@ export interface AgentPresetSeatState { /** A rejected apply's message, cleared by the next attempt. */ error: string | null busy: boolean + /** + * One-shot cue that the chip should introduce itself (the creator-draft + * entry staged the pick from another screen, so the user never touched the + * chip); the renderer clears it via `introduced()` once played. + */ + introduce: boolean } const INITIAL: AgentPresetSeatState = { - options: [], current: '', error: null, busy: false, + options: [], current: '', error: null, busy: false, introduce: false, } /** One session's identity and whether it has started. */ @@ -121,10 +127,18 @@ export class AgentPresetSeatController { * list-change applier, which fires when the started session becomes * current. * @param id - the preset to stage. + * @param introduce - true when the stage came from another screen and the + * chip should announce itself on the session it lands on. */ - stage(id: string): void { + stage(id: string, introduce = false): void { this.staged = id - this.set({ current: id, error: null }) + this.set({ current: id, error: null, introduce }) + } + + /** Acknowledge the introduction cue once the chip has played it. */ + introduced(): void { + if (!this.store.getSnapshot().introduce) return + this.set({ introduce: false }) } /** diff --git a/packages/client/ui-agent-preset/src/invariant.ts b/packages/client/ui-agent-preset/src/invariant.ts index 1794763066..8420348123 100644 --- a/packages/client/ui-agent-preset/src/invariant.ts +++ b/packages/client/ui-agent-preset/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-agent-preset' diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 23e1944948..96f47975ee 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -5,7 +5,7 @@ * that are already showing, so a default set from one converges the other. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' @@ -496,6 +496,15 @@ describe('ui-agent-preset apply', () => { expect(section.startCreatorDraft).toBeDefined() expect(seat.hooks.agentPresetSeat.getSnapshot().current).toBe('cordis') expect(workspaces.starts).toHaveLength(1) + + // A cross-screen stage carries the introduce cue; the chip acknowledges + // it once, and a repeat acknowledgement leaves the snapshot untouched. + expect(seat.hooks.agentPresetSeat.getSnapshot().introduce).toBe(true) + seat.introduced() + const acknowledged = seat.hooks.agentPresetSeat.getSnapshot() + expect(acknowledged.introduce).toBe(false) + seat.introduced() + expect(seat.hooks.agentPresetSeat.getSnapshot()).toBe(acknowledged) conversation() }) diff --git a/packages/client/ui-agent-preset/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx index 8a37a7af43..0c29175a60 100644 --- a/packages/client/ui-agent-preset/tests/components.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.spec.tsx @@ -41,6 +41,7 @@ const SEAT_READY: AgentPresetSeatState = { ], busy: false, error: null, + introduce: false, } function renderRow(state: Partial = {}) { @@ -56,7 +57,11 @@ function renderRow(state: Partial = {}) { function renderSeat(state: Partial = {}) { const store = createSnapshotStore({ ...SEAT_READY, ...state }) - const actions = { load: vi.fn(() => Promise.resolve()), select: vi.fn(() => Promise.resolve()) } + const actions = { + load: vi.fn(() => Promise.resolve()), + select: vi.fn(() => Promise.resolve()), + introduced: vi.fn(), + } render( { }) }) +describe('the chip introduce cue', () => { + afterEach(() => { + vi.useRealTimers() + vi.unstubAllGlobals() + }) + + /** Character spans carry inline animation delays; nothing else does. */ + function delayedChars(): HTMLElement[] { + return Array.from(screen.getByRole('button').querySelectorAll('[style]')) + } + + it('reveals a long Latin name inside the shared window, then acknowledges', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + vi.useFakeTimers() + const actions = renderSeat({ + current: 'creator', + options: [{ id: 'creator', trust: 'user', name: 'CreatorMode' }], + introduce: true, + }) + + // Eleven characters split the 200ms window into 20ms steps, where the + // fixed 40ms tick would have doubled the run for a Latin name. + const chars = delayedChars() + expect(chars.map(span => span.textContent).join('')).toBe('CreatorMode') + expect(chars[0]!.style.animationDelay).toBe('150ms') + expect(chars[1]!.style.animationDelay).toBe('170ms') + expect(chars[10]!.style.animationDelay).toBe('350ms') + + // 150 delay + 200 window + 400 fade: acknowledged only once the last + // character has settled, and the label is plain text again after. + act(() => { vi.advanceTimersByTime(749) }) + expect(actions.introduced).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + expect(actions.introduced).toHaveBeenCalledTimes(1) + expect(delayedChars()).toHaveLength(0) + }) + + it('keeps the per-tick cap for a short CJK name', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + vi.useFakeTimers() + renderSeat({ + current: 'creator', + options: [{ id: 'creator', trust: 'user', name: '创造模式' }], + introduce: true, + }) + + // Four characters fit under the window, so the 40ms tick applies as-is. + const chars = delayedChars() + expect(chars).toHaveLength(4) + expect(chars[1]!.style.animationDelay).toBe('190ms') + expect(chars[3]!.style.animationDelay).toBe('270ms') + }) + + it('starts a one-character name with no stagger at all', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + vi.useFakeTimers() + const actions = renderSeat({ + current: 'creator', + options: [{ id: 'creator', trust: 'user', name: 'C' }], + introduce: true, + }) + + expect(delayedChars()[0]!.style.animationDelay).toBe('150ms') + act(() => { vi.advanceTimersByTime(550) }) + expect(actions.introduced).toHaveBeenCalledTimes(1) + }) + + it('skips the run under reduced motion and acknowledges at once', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: true }))) + const actions = renderSeat({ introduce: true }) + + expect(actions.introduced).toHaveBeenCalledTimes(1) + expect(delayedChars()).toHaveLength(0) + }) + + it('acknowledges an empty staged name without arming a run', () => { + vi.stubGlobal('matchMedia', vi.fn(() => ({ matches: false }))) + const actions = renderSeat({ + current: 'creator', + options: [{ id: 'creator', trust: 'user', name: '' }], + introduce: true, + }) + + expect(actions.introduced).toHaveBeenCalledTimes(1) + expect(delayedChars()).toHaveLength(0) + }) +}) + describe('the session-header label', () => { it('names the preset the session runs, and never offers a switch', async () => { const { load } = renderLabel({ blank: false, agentPreset: 'standard' }) diff --git a/packages/client/ui-agent-preset/tests/invariant.spec.ts b/packages/client/ui-agent-preset/tests/invariant.spec.ts index 300e561856..b6206763e1 100644 --- a/packages/client/ui-agent-preset/tests/invariant.spec.ts +++ b/packages/client/ui-agent-preset/tests/invariant.spec.ts @@ -1,7 +1,7 @@ /** The package's node half: an empty host body and an explained empty invariant companion. */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as AgentPresetInvariant from '@deepseek-ai/dsh-client-ui-agent-preset/invariant' diff --git a/packages/client/ui-agent-preset/tests/section.spec.tsx b/packages/client/ui-agent-preset/tests/section.spec.tsx index 05c2b28d67..6cf819b2bd 100644 --- a/packages/client/ui-agent-preset/tests/section.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.spec.tsx @@ -6,8 +6,8 @@ * action follows the host's desktop capability. */ -import { cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' import { AgentPresetSection } from '../src/client/AgentPresetSection.tsx' @@ -253,6 +253,20 @@ describe('the preset list', () => { expect(actions.close).toHaveBeenCalledTimes(1) }) + it('keeps the empty custom group on screen: heading plus the creator entry', () => { + renderSection({ + rows: [ + { id: 'standard', trust: 'system', isDefault: true, name: '标准模式' }, + { id: 'cordis', trust: 'system', isDefault: false, name: '创造模式' }, + ], + }) + + // No member yet, but the place where one's own preset will appear stays. + expect(screen.getByRole('heading', { name: en.customGroup })).toBeTruthy() + expect(screen.getByRole('button', { name: en.creatorDraft })).toBeTruthy() + expect(screen.queryByText(`· ${en.userTrust}`)).toBeNull() + }) + it('hides the creator entry without the flow or the preset, disables it without a root', () => { renderSection() expect(screen.queryByRole('button', { name: en.creatorDraft })).toBeNull() @@ -438,3 +452,68 @@ describe('deleting a preset', () => { expect(actions.remove).not.toHaveBeenCalled() }) }) + +describe('a long card description', () => { + /** jsdom has no ResizeObserver; the description watches its own box through one. */ + class ResizeObserverStub { + observe(): void {} + unobserve(): void {} + disconnect(): void {} + } + + const LONG = '始终用简体中文交流的友好通用助手,提供持久 bash 与文件编辑能力。'.repeat(8) + + /** Force the clamp to report an overflow: jsdom lays nothing out, so both heights are 0. */ + function clamp(overflowing: boolean): void { + vi.spyOn(Element.prototype, 'scrollHeight', 'get').mockReturnValue(overflowing ? 400 : 80) + vi.spyOn(Element.prototype, 'clientHeight', 'get').mockReturnValue(80) + } + + beforeEach(() => { vi.stubGlobal('ResizeObserver', ResizeObserverStub) }) + afterEach(() => { + vi.unstubAllGlobals() + vi.restoreAllMocks() + }) + + it('offers the whole description on hover once the card cuts it off', () => { + clamp(true) + vi.useFakeTimers() + try { + renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: LONG }] }) + + fireEvent.mouseEnter(within(rowFor('zh')).getByText(LONG)) + act(() => { vi.advanceTimersByTime(400) }) + + expect(screen.getByRole('tooltip').textContent).toBe(LONG) + } finally { + vi.useRealTimers() + } + }) + + it('stays quiet when the description already fits', () => { + clamp(false) + vi.useFakeTimers() + try { + renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, name: '中文助手', description: '短描述。' }] }) + + fireEvent.mouseEnter(within(rowFor('zh')).getByText('短描述。')) + act(() => { vi.advanceTimersByTime(400) }) + + // A bubble repeating what is already fully on the card is noise. + expect(screen.queryByRole('tooltip')).toBeNull() + } finally { + vi.useRealTimers() + } + }) + + it('renders where the runtime has no ResizeObserver', () => { + vi.unstubAllGlobals() + clamp(true) + + expect(() => { + renderSection({ rows: [{ id: 'zh', trust: 'user', isDefault: false, description: LONG }] }) + }).not.toThrow() + // The first measurement does not depend on the observer. + expect(within(rowFor('zh')).getByText(LONG).getAttribute('title')).toBe('') + }) +}) diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index df3c88a678..93c0cf3910 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-command", "description": "Client command surface: global directory cache, '/' source, three command UI kinds, popupSelect registry", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-command" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -42,15 +49,15 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -64,7 +71,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-command/src/client/index.ts b/packages/client/ui-command/src/client/index.ts index d65ab0f78e..f8de391fad 100644 --- a/packages/client/ui-command/src/client/index.ts +++ b/packages/client/ui-command/src/client/index.ts @@ -28,7 +28,7 @@ export type { } from './contract.ts' export type { CommandKey } from './locales.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { command: CommandService } diff --git a/packages/client/ui-command/src/client/service.ts b/packages/client/ui-command/src/client/service.ts index 866ff89c9d..00515c1e41 100644 --- a/packages/client/ui-command/src/client/service.ts +++ b/packages/client/ui-command/src/client/service.ts @@ -7,8 +7,8 @@ * addresses the session's agent by sessionId — sessions are always * agent-backed. */ -import { Service } from 'cordis' -import type { Context } from 'cordis' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { ClientContext, ISessions } from '@deepseek-ai/dsh-client-runtime/client' import type { diff --git a/packages/client/ui-command/src/invariant.ts b/packages/client/ui-command/src/invariant.ts index 2d38b762a9..734fef9466 100644 --- a/packages/client/ui-command/src/invariant.ts +++ b/packages/client/ui-command/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-command' diff --git a/packages/client/ui-command/tests/browser-plugin.spec.ts b/packages/client/ui-command/tests/browser-plugin.spec.ts index f093548f3e..ae2afff1f5 100644 --- a/packages/client/ui-command/tests/browser-plugin.spec.ts +++ b/packages/client/ui-command/tests/browser-plugin.spec.ts @@ -6,7 +6,7 @@ * scope → popupFor; unknown id fails loud), both fold up on fiber disposal * (HMR safety), and the service satisfies the frozen CommandServiceContract. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { createScope, scopeOf, SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-command/tests/service.spec.ts b/packages/client/ui-command/tests/service.spec.ts index f7ee172b8e..b5029761fa 100644 --- a/packages/client/ui-command/tests/service.spec.ts +++ b/packages/client/ui-command/tests/service.spec.ts @@ -7,7 +7,7 @@ * payload, the scoped consume-token dispatch, per-session popupFor * lifecycle, and the directory invalidation event subscriptions. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 4f28cf5627..812cc04c1c 100644 --- a/packages/client/ui-conversation/README.i18n.yaml +++ b/packages/client/ui-conversation/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/client/ui-conversation/README.md -README.md: 7c4855a75abb982ff55b903808d6a65c42cbc91c -README.zh.md: c3a5d7beb2e90289f4340fc251fd3527370e3e23 +README.md: 605bba15d704c0c6e9f28abb3cddeb68bdd7e0d8 +README.zh.md: e6a2dd0b545b66ab01b213b5ebc937e22af8ac1a diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 7c4855a75a..605bba15d7 100644 --- a/packages/client/ui-conversation/README.md +++ b/packages/client/ui-conversation/README.md @@ -6,7 +6,7 @@ Conversation domain: skeleton (header/tabs/composer/empty state), chat view (gro Compaction renders as one collapsed row at the checkpoint's flow position without replacing the transcript above it. Automatic compaction uses the context-compacted title. Every completed marker with a loaded `compact/summary` event shows the replaced-item and estimated-token counts and discloses the summary on click. Manual `/compact` starts as a running `compact` row; on successful settlement its explicit summary-event reference folds that command into the checkpoint row under the same React key. A completed checkpoint keeps the context-compaction icon at rest and replaces it with the collapsed or expanded disclosure only on hover or keyboard focus. Input rejection, no compactable history, cancellation, and failure retain the generic command row and its handler-authored text. Pairing never depends on adjacency because durable context may be injected while compaction is running. The framed checkpoint payload is model-facing and never renders; when the cited `compact/summary` event is outside the loaded window, the checkpoint remains visible but non-expandable. -The resident conversation shell survives no-session and session transitions. Without a current session it renders a disabled input bar; its root-scoped `conversation.hero.workspace` slot hosts the Workspace picker. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. +The resident conversation shell survives no-session and session transitions. Without a current session it locks message actions and presents the whole dashed composer card as a trigger for the root-scoped `conversation.hero.workspace` Workspace picker; the textarea remains read-only and keyboard-accessible. Selecting a Workspace connects or reuses its Host-owned blank session and opens that session without replacing the shell. The root always owns the same scrollport and Hero/composer subtree; separate strict-session header and body outlets fill their regions when the first Session arrives, so the Workspace picker, scroll body, composer seat, and textarea retain their React and DOM identity. Blank sessions render the same composer body as active sessions, while the InputHub carries drafts across Workspace switches and mirrors them into the session store. In the active phase the session header shows only the current session title and view tabs as ordinary column chrome; fork lineage remains session data and is not projected into the header. Beneath it the scrollport (`data-conversation-scroll`) holds the flowing views and the sticky composer stack (stats dock + input docks + bar). That scrollport reserves its scrollbar gutter unconditionally, and a view opting into a composer overlay leaves it a scroll container, so the input card keeps one horizontal position whether or not the transcript scrolls and whichever view tab is shown ([decision](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md)). Wheel over the textarea chains: the capped draft scrolls locally until its edge, then forwards to that host. Another plugin can make one session's composer inert through `ctx.conversation.blocks`: it sets a block carrying its own localized reason, and the bar renders the same disabled textarea with that reason as the placeholder — the no-workspace posture, reused. The push direction is the constraint, not a preference: the plugins that know a session cannot send (ui-model, when no adapter serves its route) already depend on this package, so this package cannot read them. The model seat is the one control a block leaves live — every block this contract has is cleared by choosing a model, so locking it too would leave the composer asking for the only thing it prevents. A block is an affordance only; the Host refuses a prompt it cannot route regardless of what any client disables. The no-workspace state wins when both hold, because picking a workspace is the earlier prerequisite. @@ -36,7 +36,7 @@ Keyboard message submission resolves delivery from the addressed session's runni Per-session UI state for selection and the active view lives in the declared chat store (`stores.ts` `createChatStore`); the InputHub owns the composer state machine and mirrors its draft into that store for persistence. Apply passes one store handle to the strict session subtree, chat view, and details registrations, so each session shares one instance and the framework owns its lifecycle. Components are pure: the framework standard kit supplies `useSession`/`sessionId`, global `useSessions`/`useWorkspaces`, and the input machine's `useInput`/`inputActions`; store faces and inject factories supply the remaining state and callbacks. -The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar renders inert (machine faces absent, `disabled` owner prop) instead of swapping in a parallel disabled tree, so the textarea DOM survives the workspace pick; the strict-session control seats simply stay empty until a session exists. +The composer bar declares session-scoped single seats for `'conversation.input.plan'` (right of the local access-mode control) and `'conversation.input.model'` (immediately before the pending indicator and send/stop controls), plus list slots for overlay, dock, left, and right input extensions. Feature packages own each control and its state; ui-conversation supplies placement, the `locked` owner prop, and the standard slot shares. The leading plus button is a Command launcher, not an attachment surface: it asks the session's `SlashController` to open only the `/` trigger's `command` source over the current textarea selection, while ui-slash's existing `MenuView` remains the sole floating menu and pick path. No file row, file input, upload protocol, or second menu component is introduced. While the `plan` projection's effective target is plan mode, InputBar swaps its textarea placeholder to the plan-task wording, localized through the `conversation` locale namespace this package registers (the `placeholder.plan` / `hint.plan` keys) and shared verbatim with the claimed `/plan` command hint (a host-folded value read through the standard-kit `useProjection`; owner-supplied placeholders win). A pending composer takeover remains mounted when another conversation view is active so the blocked agent can still receive its answer; without a pending interaction, the active-session composer belongs to Chat. The composer-bar slot itself is `session-maybe`: with no current session the same bar keeps message actions inert (machine faces absent, `disabled` owner prop), while the whole dashed card opens the existing Workspace picker by pointer and the read-only textarea opens it through Enter or Space. Disabled controls release pointer events to the card, and the card contains `pointerdown` so the open picker's outside-close cannot race a reopen. The bar never swaps in a parallel tree, so the textarea DOM survives Workspace selection; strict-session control seats stay empty until a session exists. The chat stats line takes its token accounting from the generic token-meter `tokenUsage` projection read through the standard-kit `useProjection`: billed input is uncached input plus cache reads and writes; cache hit divides cache reads by that total. Visible nodes supply only the turn and step counts plus the LLM and tool wall times, which are window-scoped facts about what is on screen rather than accounting; durable token and context groups remain visible when compaction leaves no assistant node in the loaded window. The same window fold averages each recorded step's TTFT and divides sampled output tokens by their summed decode spans into a latency/throughput group localized through the `conversation` locale namespace (`TTFT avg … · … tok/s` in English); a step missing a timing boundary or a usage sample drops out of those figures instead of skewing them. The turn-count, step-count, duration, cache, and token labels use the same namespace. Each settled turn additionally appends hover-revealed `TTFT {s}s · {tps} tok/s` labels to its assistant footer after the `Ran for` duration — the turn's first-step TTFT and its turn-aggregate decode throughput — gated on the turn's timing being in the loaded window (a contiguous log suffix, so an in-window turn carries every one of its steps) and omitting whichever figure is unrecorded. A deployment without token-meter drops the token groups; when the line overflows, it elides with an ellipsis and a delayed hover tooltip carries the full text only while actually clipped. Context occupancy renders as the composer's trailing ContextMeter: a 14px occupancy ring after the model seat, fed by `contextPressure` and rendered only once both a numerator and a route capacity are known, that click-opens a panel pairing the `percent used` header and `~used / capacity` figures with a color-segmented bar and `~`-prefixed heuristic composition rows (system prompt, tools, messages) from the `contextBreakdown` projection. The ring and header read `projectedTokens` — the provider sample carried forward over the surface's movement since — so a compaction registers immediately instead of after a further turn; the composition rows stay wholly heuristic and therefore still do not sum to the header ([rationale](../../llm/token-meter/README.md)). Occupancy is deliberately an approximation: numerator and capacity are independent last-wins projection fields, not one atomic request observation. diff --git a/packages/client/ui-conversation/README.zh.md b/packages/client/ui-conversation/README.zh.md index c3a5d7beb2..e6a2dd0b54 100644 --- a/packages/client/ui-conversation/README.zh.md +++ b/packages/client/ui-conversation/README.zh.md @@ -6,7 +6,7 @@ 压缩(compaction)在检查点自身的消息流位置渲染为一行折叠标记,不替换其上方的 transcript(文本记录)。自动压缩使用「上下文已压缩」标题。每个已加载对应 `compact/summary` 事件的完成标记都会显示被替换条目数量和估算 token 数量,并可点击展开摘要。手动 `/compact` 开始时显示为运行中的 `compact` 行;成功结算后,其显式摘要事件引用会在保持同一 React key 的前提下把该命令折叠进检查点行。完成的检查点静止时保留上下文压缩图标,仅在悬停或键盘聚焦时将其替换为收起/展开指示图标。输入被拒绝、没有可压缩历史、取消和失败时仍使用通用命令行及处理器撰写的文本。配对绝不依赖相邻关系,因为压缩运行期间可能注入持久上下文。面向模型的带框检查点载荷绝不渲染;被引用的 `compact/summary` 事件位于已加载窗口之外时,检查点仍然可见但不可展开。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `conversation.hero.workspace` slot 承载 Workspace 选择器。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace 选择器、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张虚线编辑器卡片成为根作用域 `conversation.hero.workspace` Workspace picker 的入口;textarea 保持只读且支持键盘操作。选择 Workspace 会连接或复用由 Host 拥有的空白会话,并在不替换会话壳的情况下打开该会话。根组件始终拥有同一个滚动容器与 Hero/编辑器子树;首个会话到达时,彼此独立的严格会话页头和主体 outlet 只填入各自区域,因此 Workspace picker、滚动主体、编辑器 seat 与 textarea 都保留原有 React 和 DOM identity。空白会话与活跃会话渲染相同的输入区主体;InputHub 则在 Workspace 切换间携带草稿,并将草稿镜像到会话 store。活跃阶段,会话标题栏作为普通列 chrome,仅显示当前会话标题和视图标签;fork 谱系仍保留为会话数据,不投影到标题栏。其下滚动容器(`data-conversation-scroll`)承载流动排版的各视图与 sticky 编辑器栈(统计 dock+输入区 dock+输入栏)。该滚动容器无条件预留自己的滚动条槽,选用编辑器 overlay 的视图也仍把它保留为滚动容器,因此无论对话记录是否滚动、无论展示哪个视图标签,输入卡片都保持同一个横向位置([决策](../../../.agents/notes/implemented/bug-fix/2026-08-04-composer-tab-gutter-reservation.md))。textarea 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 别的插件可以经 `ctx.conversation.blocks` 让某个会话的编辑器变为惰性:它设置一个携带自己本地化理由的 block,输入栏就渲染同一个禁用的 textarea,并把该理由作为 placeholder——复用无 Workspace 时的那套姿态。推送方向是约束而非偏好:知道某会话发不出消息的插件(ui-model,在没有适配器服务其路由时)本就依赖本包,因此本包读不到它们。模型 seat 是 block 唯一保留可用的控件——这份约定里的每个 block 都靠选模型来解除,把它一起锁上会让编辑器索要它自己拦下的那件事。block 只是提示性设计;无论客户端禁用了什么,宿主都会拒绝一个它路由不了的 prompt。两者同时成立时以无 Workspace 姿态为准,因为选 Workspace 是更靠前的前提。 @@ -36,7 +36,7 @@ Host 带 placement 的 `session/queue` 快照也会携带待处理 steering。Qu 逐 Session UI 状态中的选择与活跃视图位于已声明的聊天 store(`stores.ts` `createChatStore`)中;InputHub 拥有输入区状态机,并将草稿镜像到该 store 以便持久化。apply 将同一个 store handle 传给严格限定于会话的子树、聊天视图和详情注册,因此每个会话内共享一个实例,框架拥有其生命周期。组件保持纯粹:框架标准工具包提供 `useSession`/`sessionId`、全局 `useSessions`/`useWorkspaces`,以及输入状态机的 `useInput`/`inputActions`;store 表层与 inject factory 提供其余状态和回调。 -输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 以不可交互状态渲染(machine face 均缺席、`disabled` owner prop),而不是换入一棵平行的 disabled 树,因此选择 workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 +输入栏为 `'conversation.input.plan'`(位于本地 access 模式控件右侧)和 `'conversation.input.model'`(渲染在 pending 指示器与发送/停止控件之前)声明会话作用域的单实例 seat,并为 overlay、dock、left 和 right 输入扩展声明列表 slot。各功能包拥有相应控件及其状态;ui-conversation 提供放置位置、`locked` owner prop 和标准 slot share。前置加号按钮是 Command launcher,而非附件入口:它要求当前会话的 `SlashController` 基于 textarea 当前 selection,只打开 `/` trigger 的 `command` source,同时 ui-slash 既有的 `MenuView` 仍是唯一的浮层菜单与 pick 路径。不引入 File 行、file input、上传协议或第二套菜单组件。当 `plan` 投影的有效目标为 plan mode 时,InputBar 将文本框 placeholder 切换为 plan 任务措辞,经本包注册的 `conversation` locale 命名空间(`placeholder.plan` / `hint.plan` 键)本地化,并与已认领 `/plan` 命令的提示逐字共用同一份文案(经标准套件 `useProjection` 读取的 host 折叠值;owner 提供的 placeholder 优先)。另一个会话视图活跃时,待处理的 composer 接管仍保持挂载,使被阻塞的 agent(智能体)仍能收到回答;没有待处理交互时,活跃会话的 composer 归 Chat 所有。composer bar slot 本身为 `session-maybe`:没有当前会话时,同一个 bar 会让消息操作保持不可交互(machine face 均缺席、`disabled` owner prop),整张虚线卡片可经指针打开现有 Workspace picker,只读 textarea 也可通过 Enter 或 Space 打开。禁用控件会把指针事件交给卡片,卡片也会拦下 `pointerdown`,避免已打开 picker 的外点关闭与重新打开发生竞态。它不会换入一棵平行树,因此选择 Workspace 时 textarea DOM 不会被销毁;严格会话作用域的控件 seat 在会话存在之前保持为空。 聊天统计行的 token 账目来自经标准套件 `useProjection` 读取的通用 token-meter 投影 `tokenUsage`:计费输入为未缓存输入、缓存读取与缓存写入之和;缓存命中率以缓存读取除以该总量。可见节点只提供轮次与步骤计数,以及 LLM(大语言模型)和工具的墙钟时间:这些是关于「屏幕上有什么」的窗口作用域事实,而非账目;压缩(compaction)使已加载窗口不再包含 assistant 节点时,持久 token 与上下文分组仍保持可见。同一次窗口折算还会把每个有完整记录的步骤的 TTFT(首 token 延迟)取平均,并用采样到的输出 token 数除以其解码时长之和,得到经 `conversation` locale 命名空间本地化的延迟/吞吐分组(中文为 `首 token 平均 … · … tok/s`);缺少某个 timing 边界或 usage 采样的步骤会直接退出这些数字,而不是让它们失真。轮次计数、步骤计数、耗时、缓存与 token 各项的标签也使用同一命名空间。每个已结算轮次还会在其 assistant footer 的 `用时` 之后追加 hover 才显示的 `首 token {s}秒 · {tps} tok/s` 标签——即该轮次首个步骤的 TTFT 与轮次聚合的解码吞吐——仅当该轮次的 timing 位于已加载窗口内才显示(窗口是日志的连续后缀,因此窗口内的轮次必然带着它的全部步骤),未记录的数字会各自省略。未组合 token-meter 的部署会整组省略 token 分组;统计行过长时以省略号截断,仅在内容真的被裁切时由延迟 hover tooltip 承载完整文本。上下文占用率渲染为 composer 尾部的 ContextMeter:模型座位之后的一枚 14px 占用圆环,由 `contextPressure` 供数,仅当分子与路由容量都已知时才渲染;点击弹出的面板把「已用百分比」标题与 `~已用 / 容量` 数字,与来自 `contextBreakdown` 投影、带 `~` 前缀的启发式组成明细行(系统提示词、工具、对话消息)及分色分段进度条并列。圆环与标题读取 `projectedTokens`——把提供方样本沿此后表层的增减推进到当下——因此压缩会立刻反映出来,而不必再等一整轮;组成明细行仍是纯启发式,因此加起来依然不等于标题数字([原理](../../llm/token-meter/README.md))。占用率是刻意为之的近似值:分子与容量是两个相互独立的「后写覆盖」投影字段,并非同一次请求的原子观测。 diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index 5dda7ef637..e80432f79f 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-conversation", "description": "Conversation domain: skeleton, ordered chat flow, composer with the Host-backed busy-Enter preference, and details host", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-conversation" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -41,25 +48,25 @@ "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", "clsx": "^2.0.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-attachment": "^0.0.1", - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-client-connection": "^0.0.1", - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-compact": "^0.0.1", - "@deepseek-ai/dsh-commands": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm-retry": "^0.0.1", - "@deepseek-ai/dsh-token-meter": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -86,7 +93,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-conversation/src/client/apply.ts b/packages/client/ui-conversation/src/client/apply.ts index cad25cc84e..5449295055 100644 --- a/packages/client/ui-conversation/src/client/apply.ts +++ b/packages/client/ui-conversation/src/client/apply.ts @@ -1,5 +1,5 @@ /** Registers the conversation components, shared store, and service callbacks. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { resolveSlotLabel, type BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import { bindSettingsScope, resolveWorkspacePath, type ISessions, type SessionId, diff --git a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts index 8926fc2a8e..78aa36d136 100644 --- a/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts +++ b/packages/client/ui-conversation/src/client/chat/register-node-renderers.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { NS } from '../locales.ts' import { AssistantNodeView } from './AssistantNodeView.tsx' import { CommandNodeView, ManualCompactionNodeView } from './CommandNodeView.tsx' diff --git a/packages/client/ui-conversation/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index bbca9254bc..602e54d5d3 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -122,8 +122,9 @@ declare module '@deepseek-ai/dsh-client-ui-slots' { * takeover election hides rather than unmounts it and the textarea DOM * survives). Session-maybe: the bar stays mounted across the * no-session/session transition — the no-workspace hero renders the SAME - * textarea DOM disabled instead of a parallel inert tree — with the - * machine hooks absent until a session is current. InputBar registers + * textarea DOM as a read-only Workspace-picker trigger instead of a + * parallel inert tree — with the machine hooks absent until a session is + * current. InputBar registers * here from this package's apply; its machine state arrives through the * standard provide channel (useInput + inputActions), the keyboard * command face through its own inject. @@ -228,7 +229,7 @@ export interface ChatFileMentions { forClosing(owner: TurnTailOwnerProps): MarkdownFileMentions | undefined } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Prose file-mention provider (ui-deliverables); reach via ctx.get — optional. */ chatFileMentions: ChatFileMentions @@ -380,11 +381,14 @@ export interface ComposerBarOwnerProps { */ blocked?: { readonly reason: string } /** - * Inert no-workspace state: the bar renders its normal DOM fully disabled - * (textarea, add, send) so the workspace pick transitions in place instead - * of swapping component trees. + * Inert no-workspace state: the bar locks message actions while preserving + * its normal DOM so the Workspace pick transitions in place. */ disabled?: boolean + /** Whether the shared Workspace picker menu is expanded, regardless of which trigger opened it. */ + workspacePickerOpen?: boolean + /** Open the existing Workspace picker from the inert textarea. */ + onRequestWorkspace?: () => void placeholder?: string /** Optional content rendered above the textarea. */ accessory?: ReactNode diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 83df1e0df6..641a0287e4 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, @@ -242,6 +242,7 @@ function projectAssistant(context: ConversationNodeContext): Ass /** Per-step Assistant streaming/final/interruption Definition. */ export const assistantDefinition: ConversationNodeDefinition = { kind: 'assistant-step', + target: 'chat', match: (event) => { if (event.type === 'step/start') return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } if (event.type === 'assistant/chunk' @@ -291,8 +292,7 @@ export const assistantDefinition: ConversationNodeDefinition = { value: projected.data, } }, - buildViewNode: (context, target) => { - if (target !== 'chat') return null + buildViewNode: (context) => { const projected = projectAssistant(context) if (projected === undefined) return null if (projected.settled === undefined && !projected.visible) { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts index f417c33b75..8b5a506030 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/chat-snapshot-builder.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ChatConversationViewNode, ChatLocationNodeIndex, ChatNodeStore, ChatSnapshot, ConversationLocation, ConversationNode, ConversationTimelineSnapshot, diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts index 752d7e3dd2..692666fb66 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { CommandNode, CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, @@ -175,6 +175,7 @@ export function updateCompactionState( /** Slash-command lifecycle, including integrated manual compaction, Definition. */ export const commandDefinition: ConversationNodeDefinition = { kind: 'command', + target: 'chat', match: (event) => { if (event.type === 'command/run') { return { id: String(event.data.commandId), role: 'start' } @@ -202,8 +203,7 @@ export const commandDefinition: ConversationNodeDefinition = { } return updateCompactionState(context.state, match) }, - buildViewNode: (context, target) => { - if (target !== 'chat') return null + buildViewNode: (context) => { const state = context.state ?? fallbackState(context) if (state === undefined) return null if (state.command.name !== 'compact') { diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts index d21b4aa4b4..18f3205df7 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { CompactionSummaryNode, ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, } from '@deepseek-ai/dsh-client-runtime/client' @@ -30,6 +30,7 @@ function fallbackState(context: ConversationNodeContext): Compa /** Automatic compaction lifecycle and landed checkpoint Definition. */ export const compactionDefinition: ConversationNodeDefinition = { kind: 'compaction', + target: 'chat', match: (event) => { const checkpoint = compactSource(event) if (checkpoint !== undefined && checkpoint.sourceCommandId === undefined) { @@ -47,8 +48,7 @@ export const compactionDefinition: ConversationNodeDefinition = }, start: () => ({}), update: (context, match) => updateCompactionState(context.state, match), - buildViewNode: (context, target) => { - if (target !== 'chat') return null + buildViewNode: (context) => { const state = context.state ?? fallbackState(context) if (state.checkpoint === undefined) return null const marker = compactSummary(state.summary, state.checkpoint) diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts index a93fc8fdd4..79bc97e636 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition, UnknownSurfaceNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -15,6 +15,7 @@ declare module '@deepseek-ai/dsh-client-ui-conversation/client' { /** Unclaimed append-surface fallback Definition. */ export const unknownFallbackDefinition: ConversationNodeDefinition = { kind: 'unknown-surface', + target: 'chat', match: event => isAppendSurfaceEvent(event) ? { id: String(event.seq), role: 'start' } : null, @@ -26,7 +27,7 @@ export const unknownFallbackDefinition: ConversationNodeDefinition context.state, - buildViewNode: (context, target) => target !== 'chat' || context.state === undefined + buildViewNode: context => context.state === undefined ? null : chatNode(context, 'unknown', context.state.seq, context.state), } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts index e3cfae47f4..4d8fb6d3e2 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationNodeDefinition, ConversationPreviousContext, } from '@deepseek-ai/dsh-client-runtime/client' @@ -50,7 +50,6 @@ function inboxDefinition(target: InboxTarget): ConversationNodeDefinition context.state, publication: () => 'none', - buildViewNode: () => null, } } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts index 91300944d7..085127f9c5 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ContextMessageNode, ConversationNodeDefinition, SteeringMessageNode, UserMessageNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -30,6 +30,7 @@ function isCompactionCheckpoint(event: Parameters = { kind: 'input-message', + target: 'chat', match: event => event.type === 'user/message' && isAppendSurfaceEvent(event) && !isCompactionCheckpoint(event) @@ -68,8 +69,8 @@ export const messageDefinition: ConversationNodeDefinition = { } }, update: context => context.state, - buildViewNode: (context, target) => { - if (target !== 'chat' || context.state === undefined) return null + buildViewNode: (context) => { + if (context.state === undefined) return null return chatNode(context, context.state.kind, context.state.seq, context.state) }, } diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts index 9102b1f2f4..bc911ad5db 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/register.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/register.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { registerAssistantConversationNode } from './assistant.ts' import { registerChatConversationView } from './chat-snapshot-builder.ts' import { registerCommandConversationNode } from './command.ts' diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts index d504f32928..4a0f9f9fed 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationLocation, ConversationNodeDefinition, ModelRetryNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -40,6 +40,7 @@ function isClosed(location: ConversationLocation): boolean { /** Producer-correlated model retry chain Definition. */ export const retryDefinition: ConversationNodeDefinition = { kind: 'model-retry', + target: 'chat', match: (event) => { if (event.type === 'llm/retry') { const retryId: unknown = event.data.retryId @@ -70,8 +71,8 @@ export const retryDefinition: ConversationNodeDefinition = { attempt.retry === retry ? { ...attempt, retryState: 'started' } : attempt), } }, - buildViewNode: (context, target) => { - if (target !== 'chat' || context.state === undefined || context.state.attempts.length === 0) return null + buildViewNode: (context) => { + if (context.state === undefined || context.state.attempts.length === 0) return null const location = context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' as const } const stateAttempts = context.state.attempts const attempts = stateAttempts.map((attempt, index) => diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts index c1b8022e41..0d6fb57cf3 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, RunningToolCall, ToolCallBlock, ToolResultNode, @@ -235,6 +235,7 @@ function fallbackState(context: ConversationNodeContext): ToolState | /** Root Tool lifecycle and nested Code Dispatch Definition. */ export const toolDefinition: ConversationNodeDefinition = { kind: 'tool-call', + target: 'chat', match: (event) => { if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } if (event.type === 'tool/result' && isAppendSurfaceEvent(event)) { @@ -257,8 +258,7 @@ export const toolDefinition: ConversationNodeDefinition = { } return updateDispatch(context.state, match) }, - buildViewNode: (context, target) => { - if (target !== 'chat') return null + buildViewNode: (context) => { const state = context.state ?? fallbackState(context) if (state === undefined) return null const projected = projectBlock(state.root, state, interruption(context)) diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts index 60b2fca087..6242276d12 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-error.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnErrorNode, } from '@deepseek-ai/dsh-client-runtime/client' @@ -63,6 +63,7 @@ function fallbackState(context: ConversationNodeContext): TurnEr /** Terminal turn failure Definition, suppressed when the turn owns a retry chain. */ export const turnErrorDefinition: ConversationNodeDefinition = { kind: 'turn-error', + target: 'chat', match: (event) => { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } if (event.type === 'turn/end' && event.data.reason.kind === 'error') { @@ -82,8 +83,7 @@ export const turnErrorDefinition: ConversationNodeDefinition = { ? { ...context.state, hidden: true } : context.state }, - buildViewNode: (context, target) => { - if (target !== 'chat') return null + buildViewNode: (context) => { const state = context.state ?? fallbackState(context) if (state?.failure === undefined) return null const failure = state.failure diff --git a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts index 2a1b4d2b13..94fb72a383 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/turn-tail.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, TurnLocation, } from '@deepseek-ai/dsh-client-runtime/client' @@ -151,6 +151,7 @@ function tailData(context: ConversationNodeContext): TurnTailChat /** Completed-turn footer Definition independent of any Assistant row. */ export const turnTailDefinition: ConversationNodeDefinition = { kind: 'turn-tail', + target: 'chat', match: (event) => { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } if (event.type === 'turn/end') return { id: String(event.data.turn), role: 'update' } @@ -179,8 +180,7 @@ export const turnTailDefinition: ConversationNodeDefinition = { value, } }, - buildViewNode: (context, target) => { - if (target !== 'chat') return null + buildViewNode: (context) => { const turn = turnLocation(context) const data = turn?.data.get('turn-tail') return data === undefined ? null : chatNode(context, 'turn-tail', closingAnchor(context), data) diff --git a/packages/client/ui-conversation/src/client/index.ts b/packages/client/ui-conversation/src/client/index.ts index afd2f3ba19..5f120157ca 100644 --- a/packages/client/ui-conversation/src/client/index.ts +++ b/packages/client/ui-conversation/src/client/index.ts @@ -36,7 +36,7 @@ export type { } from './contract/slots.ts' // Export discipline: packages/client/AGENTS.md. -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The outward face only; the concrete service stays inside this plugin. */ conversation: import('./service.ts').IConversation diff --git a/packages/client/ui-conversation/src/client/locales.ts b/packages/client/ui-conversation/src/client/locales.ts index b022219bc7..dcf04264e8 100644 --- a/packages/client/ui-conversation/src/client/locales.ts +++ b/packages/client/ui-conversation/src/client/locales.ts @@ -60,7 +60,7 @@ export const zh = { 'access.confirm.acknowledge': '我已了解风险,并愿意继续', 'access.confirm.cancel': '取消', 'access.confirm.enable': '启用 Full access', - 'hero.headline': '探索未知之境', + 'hero.headline': '探索未至之境', 'hero.preview': '预览版', 'hero.chooseWorkspace': '选择工作区', 'session.hierarchy': '会话层级', diff --git a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx index 35dac88ffd..19cc9c62c8 100644 --- a/packages/client/ui-conversation/src/client/queue/QueueDock.tsx +++ b/packages/client/ui-conversation/src/client/queue/QueueDock.tsx @@ -3,7 +3,7 @@ // // The 'conversation.input.dock' SlotMap declaration lives in // ../contract/slots.ts beside the other input-region slots. -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { useEffect, useId, useMemo, useState } from 'react' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-conversation/src/client/service.ts b/packages/client/ui-conversation/src/client/service.ts index f8d7095491..b70f58c6d9 100644 --- a/packages/client/ui-conversation/src/client/service.ts +++ b/packages/client/ui-conversation/src/client/service.ts @@ -7,8 +7,8 @@ * through one property read; assignment through the tracker proxy and `#` * private fields bypass that rebinding. */ -import { Service } from 'cordis' -import type { Context } from 'cordis' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' // Type-only imports: a plugin-to-plugin value import is a bundle purity // error, so scope resolution goes through the sessions service (scopeOf // method) instead of the standalone helper. diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css index ca15f77c4d..971661cd48 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -263,8 +263,9 @@ .composerHero { position: relative; /* .heroGlow positioning context */ align-self: center; - /* figma 75:8208: 12 between hero chrome / workspace row / card. */ - gap: 12px; + /* figma 75:8208 drew 12 between all three rows; the workspace row now sits + 8 above the card (its margin-top restores 12 under the hero chrome). */ + gap: 8px; /* Foot inside the centered box floats the stack a bit above true center. */ padding-bottom: 32px; /* Card cap + both clearances: the hero input card lands at exactly the same @@ -292,7 +293,9 @@ .heroWorkspaceRow { display: flex; align-items: center; + gap: 2px; min-width: 0; + margin-top: 4px; /* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to the card's inner controls below. */ padding-left: 20px; diff --git a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index 10f757d15e..810b9ad4f3 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -123,7 +123,7 @@ export function ConversationRoot({
) - // The placeholder chip ("Choose workspace") and the inert input travel + // The placeholder chip ("Choose workspace") and the Workspace-trigger input travel // together: no workspace picked yet (cold start, no session at all), or a // blank session whose workspace vanished (deleted from the sidebar). The // bar is ONE session-maybe slot rendered unconditionally — inert is a prop, @@ -136,7 +136,12 @@ export function ConversationRoot({ const inputBar = renderSlot('conversation.composer.bar', { variant: hero ? 'hero' : 'composer', ...(inert - ? { disabled: true, placeholder: t('placeholder.workspace') } + ? { + disabled: true, + placeholder: t('placeholder.workspace'), + workspacePickerOpen: pickerOpen, + onRequestWorkspace: () => { setPickerOpen(true) }, + } : blocked // `blocked`, not `disabled`: the bar refuses input either way, but a // block keeps the model seat live because choosing a model is how the diff --git a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css index 0e730a5b30..3d9281b96b 100644 --- a/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/HeroShell.module.css @@ -105,7 +105,7 @@ min-height: 28px; padding: 0 8px; border: none; - border-radius: 12px; + border-radius: 16px; background: transparent; color: var(--dsw-alias-label-primary); font-size: 13px; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index 4603013e65..ad1a6ed275 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -102,6 +102,40 @@ --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); } +/* No-workspace trigger state: dashed l4 stroke marks the card as a pick-a- + workspace affordance rather than a live composer; hover answers in the + business blue to invite the click. Native `dashed` has a fixed browser + pattern, so the stroke is an ::after overlay: theme-token background masked + by an SVG dash ring (stroke-width 2 centered on the box edge = 1px visible + inside), which keeps the 22px radius and both themes. */ +.cardWorkspaceTrigger { + border-color: transparent; + cursor: pointer; +} + +.cardWorkspaceTrigger::after { + content: ''; + position: absolute; + inset: -1px; + border-radius: 22px; + background: var(--dsw-alias-border-l4); + transition: background-color 100ms ease; + -webkit-mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='none' rx='22' ry='22' stroke='black' stroke-width='2' stroke-dasharray='4 4'/%3E%3C/svg%3E"); + mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg'%3E%3Crect width='100%25' height='100%25' fill='none' rx='22' ry='22' stroke='black' stroke-width='2' stroke-dasharray='4 4'/%3E%3C/svg%3E"); + pointer-events: none; +} + +/* Disabled toolbar controls neither receive nor swallow clicks in the trigger + state: pointer events fall through to the card's own click handler, making + the full capsule one pick target. */ +.cardWorkspaceTrigger :disabled { + pointer-events: none; +} + +.cardWorkspaceTrigger:hover::after { + background: var(--dsw-alias-state-business-primary); +} + .dragActive { border-color: var(--dsw-alias-state-business-primary); box-shadow: 0 0 0 2px color-mix(in srgb, var(--dsw-alias-state-business-primary) 24%, transparent), var(--dsw-shadow-lv2); @@ -313,6 +347,10 @@ cursor: not-allowed; } +.input[aria-haspopup='menu'] { + cursor: pointer; +} + .mirror { visibility: hidden; pointer-events: none; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index 6ebee98c95..5423a2bac6 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -39,8 +39,9 @@ export function InputBar({ useSession, useInput, inputActions, keyboard, addImages, removeImage, draftImages, resolveSubmitMode, toggleCommandMenu, stop, command, t, renderSlot, useNotices, useLexicon, useMenuLauncher, - useProjection, sessionId, variant, disabled: inert = false, blocked, placeholder, - accessory, overlay, leftItems, rightItems, footer, + useProjection, sessionId, variant, disabled: inert = false, blocked, + workspacePickerOpen = false, onRequestWorkspace, + placeholder, accessory, overlay, leftItems, rightItems, footer, }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) @@ -109,6 +110,12 @@ export function InputBar({ // be disabled do lock it — there is no session to choose a model for. const modelSeatLocked = removed || inert || !live const machineBusy = input?.phase === 'adjudicating' || input?.phase === 'submitting' + // The no-workspace textarea remains the resident DOM node but acts as the + // existing picker trigger. Message controls stay locked until a Session + // exists; the trigger itself is read-only rather than disabled so pointer + // and keyboard users can reach the recovery action. + const workspaceTrigger = inert && !removed && onRequestWorkspace !== undefined + const textareaDisabled = removed || (locked && !workspaceTrigger) const canSteerQueue = !locked && !machineBusy && !commandMenuOpen && empty && running && subagent === null && input.queue.some(row => row.placement === 'queued') @@ -233,8 +240,15 @@ export function InputBar({ }, []) const onKeyDown = (e: KeyboardEvent): void => { - // Absent machine (no session): the textarea is disabled so events cannot - // fire; the guard narrows the faces for the paths below. + if (workspaceTrigger) { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault() + onRequestWorkspace() + } + return + } + // Absent machine without a Workspace recovery action stays disabled; the + // guard narrows the faces for the paths below. if (keyboard === undefined || inputActions === undefined) return // Shift+Enter is the native newline UNCONDITIONALLY — decided before the // IME guard so a composition-closing Shift+Enter still breaks the line. @@ -298,7 +312,7 @@ export function InputBar({ } const onChange = (e: ChangeEvent): void => { - if (keyboard === undefined) return // absent machine: disabled textarea, no events + if (keyboard === undefined || locked) return // disabled/read-only states cannot edit the draft if (machineBusy) return // submitting is the read-only span; adjudicating holds the pending lock const next = e.target.value keyboard.setDraft(next) @@ -324,7 +338,7 @@ export function InputBar({ /* oxlint-enable typescript/no-unnecessary-condition */ const onCopyOrCut = (e: React.ClipboardEvent, cut: boolean): void => { - if (input === undefined || keyboard === undefined) return // absent machine: disabled textarea, no events + if (input === undefined || keyboard === undefined) return // absent machine: no draft can be copied or cut const el = e.currentTarget const { start, end } = selectionOf(el) if (start === end) return @@ -349,7 +363,7 @@ export function InputBar({ } const onPaste = (e: React.ClipboardEvent): void => { - if (keyboard === undefined) return // absent machine: disabled textarea, no events + if (keyboard === undefined) return // absent machine: no draft can accept a paste if (machineBusy || locked) return const files = Array.from(e.clipboardData.items) .filter(item => item.kind === 'file') @@ -539,10 +553,17 @@ export function InputBar({ {notice.text} )} + {/* Trigger clicks land on the card, not the textarea: the toolbar row's + disabled controls swallow clicks otherwise (the CSS state disarms + their pointer events), so the WHOLE capsule is the pick target. + pointerdown stops here so the Menu's outside-close cannot race the + click's reopen (close-then-open flickers the chip's open echo). */} {dropError !== null &&
{dropError}
}
{ e.stopPropagation() } : undefined} onDragEnter={onDragEnter} onDragOver={onDragOver} onDragLeave={onDragLeave} @@ -590,8 +611,11 @@ export function InputBar({ ref={inputRef} className={css.input} value={draft} - disabled={locked} - readOnly={machineBusy} + disabled={textareaDisabled} + readOnly={machineBusy || workspaceTrigger} + aria-label={workspaceTrigger ? t('hero.chooseWorkspace') : undefined} + aria-haspopup={workspaceTrigger ? 'menu' : undefined} + aria-expanded={workspaceTrigger ? workspacePickerOpen : undefined} data-phase={input?.phase ?? 'inert'} placeholder={placeholder ?? (parentOffline ? t('placeholder.parentOffline') diff --git a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css index 60aceaa120..22f64d6e61 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.module.css @@ -51,6 +51,9 @@ } .chevron { + /* inline-flex, not inline: an inline seat reserves baseline descent under + the svg and floats the glyph off-center in the 28px trigger. */ + display: inline-flex; flex: 0 0 auto; color: var(--dsw-alias-label-caption); transition: transform 120ms ease; diff --git a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx index da6faa5794..ad627e7cad 100644 --- a/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/TodoPanel.tsx @@ -6,7 +6,7 @@ // framework-free. Visual: figma 772:51905 / 772:52972 / 772:53419. import { useId, useState } from 'react' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { PropsLocale, PropsRuntime } from '@deepseek-ai/dsh-client-ui-slots' // The domain's client-namespace pure-type outlet: one import edge delivers // the `todos` projection-key merge (single source, no consumer-side restated diff --git a/packages/client/ui-conversation/src/index.ts b/packages/client/ui-conversation/src/index.ts index b49d7dcf0d..31754ae7e1 100644 --- a/packages/client/ui-conversation/src/index.ts +++ b/packages/client/ui-conversation/src/index.ts @@ -1,6 +1,6 @@ /** Host registration for browser conversation preferences. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { CONVERSATION_SETTINGS_NAMESPACE, ConversationSettingsSchema } from './submission-settings.ts' diff --git a/packages/client/ui-conversation/src/invariant.ts b/packages/client/ui-conversation/src/invariant.ts index 66c54081ab..7e0b5b9a9b 100644 --- a/packages/client/ui-conversation/src/invariant.ts +++ b/packages/client/ui-conversation/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-conversation' diff --git a/packages/client/ui-conversation/src/submission-settings.ts b/packages/client/ui-conversation/src/submission-settings.ts index 0bd42d33cf..1aa4d0363a 100644 --- a/packages/client/ui-conversation/src/submission-settings.ts +++ b/packages/client/ui-conversation/src/submission-settings.ts @@ -1,6 +1,6 @@ /** Busy-Enter preference stored in the Host user-settings document. */ -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' /** Settings namespace owned by the conversation plugin. */ export const CONVERSATION_SETTINGS_NAMESPACE = 'ui-conversation' diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 5df7a62698..9e52d2a0d5 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -82,10 +82,20 @@ describe('resident composer', () => { runtime.slots.installLocale(locale) await runtime.root.declare(LAYOUT_CHILDREN, AppRoot) await runtime.mount({ inject: [...inject], apply }) + runtime.slots.register({ name: 'conversation.hero.workspace' }, WorkspaceProbe) const view = runtime.renderRoot() const textarea = view.container.querySelector('textarea') expect(textarea).not.toBeNull() - expect(textarea!.disabled).toBe(true) + expect(textarea!.disabled).toBe(false) + expect(textarea!.readOnly).toBe(true) + expect(textarea!.getAttribute('aria-haspopup')).toBe('menu') + expect(view.getByTestId('workspace-probe').textContent).toBe('false:0') + fireEvent.click(textarea!) + expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') + expect(textarea!.getAttribute('aria-expanded')).toBe('true') + fireEvent.click(view.getByRole('button', { name: '选择工作区' })) + fireEvent.keyDown(textarea!, { key: 'Enter' }) + expect(view.getByTestId('workspace-probe').textContent).toBe('true:0') expect(view.getByRole('button', { name: '选择工作区' })).toBeTruthy() await runtime.dispose() }) @@ -111,7 +121,8 @@ describe('resident composer', () => { const textarea = view.container.querySelector('textarea')! const workspaceChip = view.getByRole('button', { name: '选择工作区' }) const workspaceProbe = view.getByTestId('workspace-probe') - expect(textarea.disabled).toBe(true) + expect(textarea.disabled).toBe(false) + expect(textarea.readOnly).toBe(true) fireEvent.click(workspaceChip) fireEvent.click(workspaceProbe) @@ -131,6 +142,7 @@ describe('resident composer', () => { expect(view.getByTestId('workspace-probe')).toBe(workspaceProbe) expect(workspaceProbe.textContent).toBe('true:1') expect(textarea.disabled).toBe(false) + expect(textarea.readOnly).toBe(false) await runtime.dispose() }) diff --git a/packages/client/ui-conversation/tests/chat-stats.spec.tsx b/packages/client/ui-conversation/tests/chat-stats.spec.tsx index 959a91c3ac..0b2d648661 100644 --- a/packages/client/ui-conversation/tests/chat-stats.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-stats.spec.tsx @@ -7,6 +7,7 @@ import { act, cleanup, fireEvent, render } from '@testing-library/react' import type { AssistantMessageNode, ConversationSnapshot, SessionId, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' +import { EMPTY_CONVERSATION_VIEWS } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { en as commonEn } from '@deepseek-ai/dsh-client-locale/src/locales/en.ts' @@ -43,7 +44,7 @@ const assistant = (seq: number, turn: number, usage?: unknown): AssistantMessage function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, chat: chatSnapshotFixture(), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index d6b4996567..f1d095ab7e 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -12,7 +12,9 @@ import type { UserMessageNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore, PendingWait } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CONVERSATION_VIEWS, PendingWait, +} from '@deepseek-ai/dsh-client-runtime/client' import { RpcId } from '@deepseek-ai/dsh-client-connection/client' import type { ChatNode, ChatNodeOwnerProps, ChatNodeViewProps, ChatViewSlotProps, SelectionTarget, UseChatNodeTurnData, @@ -47,7 +49,8 @@ type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode } function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, chat: chatSnapshotFixture(), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: chatSnapshotFixture(), nodes: [], + turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, } @@ -131,7 +134,7 @@ const compaction = (over: Partial = {}): CompactionSummar /** Empty sessions-list hook for the global standard-kit seat. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } diff --git a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx index ad644d940b..b0ce994f44 100644 --- a/packages/client/ui-conversation/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/coverage-tails.spec.tsx @@ -3,7 +3,7 @@ // without a settings service and AssistantMarkdown reasoning/unknown block arms. import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { cleanup, render } from '@testing-library/react' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' diff --git a/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx b/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx index e8d44c9bf3..573e3a97ed 100644 --- a/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx +++ b/packages/client/ui-conversation/tests/enter-behavior-row.spec.tsx @@ -16,7 +16,7 @@ afterEach(() => { function emptySessions() { return bindSnapshotSelector(createSnapshotStore({ - ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, })) } diff --git a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx index 3acdc44a2a..bcda26f6a8 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -3,7 +3,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceListState } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionProviderComponent } from '@deepseek-ai/dsh-client-ui-slots' @@ -48,7 +50,7 @@ function renderToolDetailsProbe(owners?: DetailsToolOwnerProps[]): DetailsSlotPr function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, @@ -107,7 +109,7 @@ describe('render branch tails', () => { const chat = createChatStore().create() chat.actions.select({ turnSeq: 1, callId: 'ghost' } satisfies SelectionTarget) const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) const emptyWorkspaces = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, @@ -163,7 +165,7 @@ describe('render branch tails', () => { const chat = createChatStore().create() chat.actions.select({ turnSeq: 9, callId: 'p1:code:1:code:1', toolName: 'read' } satisfies SelectionTarget) const emptyList = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) const emptyWorkspaces = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, recentWorkspaceId: undefined, diff --git a/packages/client/ui-conversation/tests/host.spec.ts b/packages/client/ui-conversation/tests/host.spec.ts index 0d16a23da2..0b470e8d47 100644 --- a/packages/client/ui-conversation/tests/host.spec.ts +++ b/packages/client/ui-conversation/tests/host.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 59c0f9eefa..f7b0669e3e 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -7,7 +7,9 @@ import { afterEach, describe, expect, it, onTestFinished, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' @@ -37,7 +39,7 @@ const SID = 's1' as SessionId function snapshotOf(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, @@ -58,6 +60,9 @@ interface BenchOptions { running?: boolean subagent?: Exclude disabled?: boolean + inert?: boolean + workspacePickerOpen?: boolean + onRequestWorkspace?: () => void promptError?: ConversationSnapshot['promptError'] /** Authoritative queue rows served to the machine overlay (empty = none). */ queue?: ConversationSnapshot['queue'] @@ -134,7 +139,7 @@ function bench(over?: BenchOptions) { useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, @@ -166,6 +171,9 @@ function bench(over?: BenchOptions) { t: over?.t ?? makeTranslate(zh, commonZh), renderSlot, variant: over?.variant ?? 'composer', + ...(over?.inert === true ? { disabled: true } : {}), + ...(over?.workspacePickerOpen !== undefined ? { workspacePickerOpen: over.workspacePickerOpen } : {}), + ...(over?.onRequestWorkspace !== undefined ? { onRequestWorkspace: over.onRequestWorkspace } : {}), ...(over?.placeholder !== undefined ? { placeholder: over.placeholder } : {}), ...(over?.accessory !== undefined ? { accessory: over.accessory } : {}), ...(over?.overlay !== undefined ? { overlay: over.overlay } : {}), @@ -783,6 +791,40 @@ describe('running and lock semantics', () => { expect(custom.textarea.placeholder).toBe('Custom placeholder') }) + it('the inert textarea opens the Workspace picker by pointer or keyboard', () => { + const onRequestWorkspace = vi.fn() + const { view, textarea } = bench({ + inert: true, + workspacePickerOpen: false, + onRequestWorkspace, + placeholder: '选择一个工作区开始', + }) + expect(textarea.disabled).toBe(false) + expect(textarea.readOnly).toBe(true) + expect(textarea.getAttribute('aria-haspopup')).toBe('menu') + expect(textarea.getAttribute('aria-expanded')).toBe('false') + expect((view.getByLabelText('命令') as HTMLButtonElement).disabled).toBe(true) + + fireEvent.click(textarea) + fireEvent.keyDown(textarea, { key: 'Enter' }) + fireEvent.keyDown(textarea, { key: ' ' }) + expect(onRequestWorkspace).toHaveBeenCalledTimes(3) + + // The WHOLE capsule is the pick target, and its pointerdown never reaches + // the document — the open picker's outside-close must not race the reopen. + const card = view.container.querySelector('[data-composer-card]') as HTMLElement + fireEvent.click(card) + expect(onRequestWorkspace).toHaveBeenCalledTimes(4) + const onDocumentPointerDown = vi.fn() + document.addEventListener('pointerdown', onDocumentPointerDown) + try { + fireEvent.pointerDown(card) + } finally { + document.removeEventListener('pointerdown', onDocumentPointerDown) + } + expect(onDocumentPointerDown).not.toHaveBeenCalled() + }) + it('the plan projection swaps the placeholder while its effective target is plan mode', () => { const active = bench({ plan: { active: true, pending: false } }) expect(active.textarea.placeholder).toBe('描述你的任务以生成计划') diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 09cfe4ca66..69a5b9d6ba 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -8,7 +8,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ClientContext, ConversationSnapshot, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' @@ -26,7 +28,7 @@ const SID = 's1' as SessionId /** Standard-props InputBar mount over a real shell (the composer-bar entry shape). */ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled?: boolean }) { const session = createSnapshotStore({ - sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: over?.running ?? false, composerPhase: 'active', removed: over?.disabled ?? false, openState: 'open', openError: null, hasMore: false, @@ -38,7 +40,7 @@ function mountBar(shell: SessionInputShell, over?: { running?: boolean; disabled useSession: bindSnapshotSelector(session), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index ad268611e2..1f9642e236 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -8,10 +8,12 @@ * itself is not a dependency of this package; the source below is the * decision-table contract at the `SlashSource` boundary. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' -import { EMPTY_CHAT_SNAPSHOT, SessionsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, SessionsService, +} from '@deepseek-ai/dsh-client-runtime/client' import { SlashService } from '@deepseek-ai/dsh-client-ui-slash/client' import type { ClientSessionContext, CommandClaim, PickOutcome, SubmitOutcome } from '@deepseek-ai/dsh-client-ui-slash/client' import { FakeApiClient, ok } from '../../runtime/tests/fake-api.ts' @@ -112,7 +114,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { actx.on('slash/input-consume-token', req => shell.consumeToken(req.guard) ? true : undefined) const wiring = shell const sessionStore = createSnapshotStore({ - sessionId, chat: EMPTY_CHAT_SNAPSHOT, + sessionId, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, @@ -124,7 +126,7 @@ async function scopedBench(register?: (slash: SlashService) => void) { useSession: bindSnapshotSelector(sessionStore), useSessions: bindSnapshotSelector(createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, })), useWorkspaces: bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-conversation/tests/queue-dock.spec.tsx b/packages/client/ui-conversation/tests/queue-dock.spec.tsx index 68170b604f..4367a74dad 100644 --- a/packages/client/ui-conversation/tests/queue-dock.spec.tsx +++ b/packages/client/ui-conversation/tests/queue-dock.spec.tsx @@ -6,7 +6,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, waitFor } from '@testing-library/react' import { useSyncExternalStore } from 'react' -import { EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' +import { + EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, QueuedMessage, SessionId, SessionListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -33,7 +35,7 @@ function row(id: string, text: string | null, preview = text ?? '[image]'): Queu function snapshotWith(queue: QueuedMessage[]): ConversationSnapshot { return { - sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue, running: true, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, promptError: null, blank: false, subagent: null, lastAgentError: null, diff --git a/packages/client/ui-conversation/tests/service-orchestration.spec.ts b/packages/client/ui-conversation/tests/service-orchestration.spec.ts index 8454aa35c8..65b07203f9 100644 --- a/packages/client/ui-conversation/tests/service-orchestration.spec.ts +++ b/packages/client/ui-conversation/tests/service-orchestration.spec.ts @@ -3,7 +3,7 @@ // TestSessions mints tagged scopes through the production createScope, so the // service's scopeOf/binding path runs against production resolution (no local // tag probe). -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import { makeTranslate, SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/ui-conversation/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 7d7596a49e..be5cd3be28 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -5,7 +5,9 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore, EMPTY_CHAT_SNAPSHOT } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CHAT_SNAPSHOT, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, SessionId, SessionListState, WorkspaceId, WorkspaceListState, WorkspaceView, } from '@deepseek-ai/dsh-client-runtime/client' @@ -70,7 +72,7 @@ const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => function conversationSnapshot(overrides: Partial = {}): ConversationSnapshot { return { - sessionId: SID, chat: EMPTY_CHAT_SNAPSHOT, + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, chat: EMPTY_CHAT_SNAPSHOT, nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, @@ -108,7 +110,7 @@ function mount( ids: listed ? [root, SID] : [root], byId: { [root]: rootRow, ...listed && { [SID]: childRow } }, current: SID, - phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore(workspaceState(workspaceRows)) const session = createSnapshotStore(snapshot) @@ -294,8 +296,12 @@ describe('ConversationRoot resident composer', () => { composerBlock: { reason: 'select a model first' }, }) const box = b.view.getByRole('textbox') as HTMLTextAreaElement - expect(box.disabled).toBe(true) + expect(box.disabled).toBe(false) + expect(box.readOnly).toBe(true) + expect(box.getAttribute('aria-haspopup')).toBe('menu') expect(box.placeholder).not.toBe('select a model first') + const modelSeat = b.seatOwners.filter(call => call.key === 'conversation.input.model').at(-1)?.owner + expect(modelSeat).toEqual({ locked: true }) }) it('keeps composer text in the machine, mirrors to the chat store, and submits through the sink', () => { @@ -356,7 +362,7 @@ describe('ConversationRoot resident composer', () => { const header = b.view.container.querySelector('header') expect(host).not.toBeNull() expect(header?.getAttribute('aria-hidden')).toBe('true') - expect(b.view.getByText('探索未知之境')).toBeTruthy() + expect(b.view.getByText('探索未至之境')).toBeTruthy() expect(b.view.getByText('预览版')).toBeTruthy() expect(b.view.queryByTestId('view-chat')).toBeNull() // The same machine-backed textarea is live in the hero, and the @@ -380,7 +386,7 @@ describe('ConversationRoot resident composer', () => { const b = mount(conversationSnapshot({ composerPhase: 'blank', blank: true, openState: 'loading' })) const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('settling') - expect(b.view.queryByText('探索未知之境')).toBeNull() + expect(b.view.queryByText('探索未至之境')).toBeNull() }) it('settling phase: a session the list has no row for settles conservatively', () => { @@ -405,7 +411,7 @@ describe('ConversationRoot resident composer', () => { // blank the column for the history round-trip. const root = b.view.container.querySelector('[data-phase]') expect(root?.getAttribute('data-phase')).toBe('hero') - expect(b.view.getByText('探索未知之境')).toBeTruthy() + expect(b.view.getByText('探索未至之境')).toBeTruthy() expect(b.view.getByRole('textbox')).toBeTruthy() }) @@ -423,7 +429,7 @@ describe('ConversationRoot resident composer', () => { expect(after.value).toBe('kept across flip') expect(b.chat.store.getSnapshot().draft).toBe('kept across flip') expect(b.view.container.querySelector('[data-conversation-scroll]')?.contains(after)).toBe(true) - expect(b.view.queryByText('探索未知之境')).toBeNull() + expect(b.view.queryByText('探索未至之境')).toBeNull() expect(b.view.getByTestId('view-chat')).toBeTruthy() }) diff --git a/packages/client/ui-conversation/tests/views-type-chain.spec.tsx b/packages/client/ui-conversation/tests/views-type-chain.spec.tsx index b055c51560..c8d384163d 100644 --- a/packages/client/ui-conversation/tests/views-type-chain.spec.tsx +++ b/packages/client/ui-conversation/tests/views-type-chain.spec.tsx @@ -1,7 +1,7 @@ // View-ring type-chain samples. This spec pins the conversation-owned SlotMap // row, list-kind registration shape, composed view props, and the runtime // ledger projection consumed by ConversationRoot. -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { ReactNode } from 'react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 07e8a1bc28..71eb96cf02 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail: the deliverables row a finished turn ends with", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-deliverables" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -41,12 +48,12 @@ "react": "^18.2.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -56,7 +63,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 9151f88869..e63bca2e63 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -136,7 +136,6 @@ export const deliverablesDefinition: ConversationNodeDefinition null, } /** diff --git a/packages/client/ui-deliverables/src/invariant.ts b/packages/client/ui-deliverables/src/invariant.ts index 39c39591cf..d14e474ca3 100644 --- a/packages/client/ui-deliverables/src/invariant.ts +++ b/packages/client/ui-deliverables/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-deliverables' diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 75f422787a..31289b38de 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -5,7 +5,7 @@ * and opener wiring, and the plugin registrations' fiber-teardown removal * (HMR safety) against the real SlotsService. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { cleanup, fireEvent, render } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { @@ -73,7 +73,7 @@ interface TimelineSnapshot { class TestEventDefinitions { entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] } - fallbackEntry(): undefined { return undefined } + fallbackEntry(): ConversationNodeDefinition | undefined { return undefined } } class TestViewDefinitions { diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 9e8b6865f9..9216af858a 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-goal", "description": "Session goal surface: GoalBar docked above the composer, read from the goal session projection", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-goal" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -39,15 +46,15 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-api-remotes": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-goal": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-api-remotes": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -62,7 +69,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-goal/src/invariant.ts b/packages/client/ui-goal/src/invariant.ts index 2120600664..93d0602d25 100644 --- a/packages/client/ui-goal/src/invariant.ts +++ b/packages/client/ui-goal/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-goal' diff --git a/packages/client/ui-goal/tests/browser-plugin.spec.tsx b/packages/client/ui-goal/tests/browser-plugin.spec.tsx index 756968136e..9ead151b65 100644 --- a/packages/client/ui-goal/tests/browser-plugin.spec.tsx +++ b/packages/client/ui-goal/tests/browser-plugin.spec.tsx @@ -10,7 +10,7 @@ * plugin fiber (HMR safety). The node half and the invariant companion are * exercised over the same Context. */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index ba41bc9dcb..232c4606ab 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-layout", "description": "Shell plugin: three-column AppFrame with drag handles, ctx.layout viewing-state service (navigation + panels)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-layout" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -37,11 +44,11 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-client-ui-theme": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-theme": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -51,7 +58,7 @@ "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-layout/src/client/index.ts b/packages/client/ui-layout/src/client/index.ts index 8050cb0916..f2269500b4 100644 --- a/packages/client/ui-layout/src/client/index.ts +++ b/packages/client/ui-layout/src/client/index.ts @@ -23,7 +23,7 @@ import { ThemePresenter } from './theme-presenter.ts' export { LayoutService } from './service.ts' export type { ILayout } from './service.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The outward face only; the concrete service stays inside this plugin. */ layout: import('./service.ts').ILayout diff --git a/packages/client/ui-layout/src/invariant.ts b/packages/client/ui-layout/src/invariant.ts index dd572e679d..266b9e5b0f 100644 --- a/packages/client/ui-layout/src/invariant.ts +++ b/packages/client/ui-layout/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-layout' diff --git a/packages/client/ui-layout/tests/apply.spec.ts b/packages/client/ui-layout/tests/apply.spec.ts index 024382fa79..1aae24bc11 100644 --- a/packages/client/ui-layout/tests/apply.spec.ts +++ b/packages/client/ui-layout/tests/apply.spec.ts @@ -6,7 +6,7 @@ // and the invariant companion ride along — one-line surfaces the aggregate // coverage gate still requires exercised. -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { beforeEach, describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index 6b6ed328fc..069fb47c8e 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-model", "description": "Model selection: the /model popupSelect over session.models / session.selectModel", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-model" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -38,17 +45,17 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-command": "^0.0.1", - "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-command": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", "clsx": "^2.1.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -63,7 +70,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "clsx": "^2.1.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-model/src/client/service.ts b/packages/client/ui-model/src/client/service.ts index 5bf2c744a3..5a35ce0039 100644 --- a/packages/client/ui-model/src/client/service.ts +++ b/packages/client/ui-model/src/client/service.ts @@ -12,13 +12,13 @@ * strings, and it models global+shadow named registries — this is a * per-session singleton with no global layer to merge. */ -import { Service } from 'cordis' -import type { Context } from 'cordis' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConnectionHandle, SessionId } from '@deepseek-ai/dsh-client-connection/client' import type { SessionsService } from '@deepseek-ai/dsh-client-runtime/client' import { ModelDirectory } from './directory.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { models: ModelService } diff --git a/packages/client/ui-model/src/invariant.ts b/packages/client/ui-model/src/invariant.ts index baac6dcc70..9b24e5c3c9 100644 --- a/packages/client/ui-model/src/invariant.ts +++ b/packages/client/ui-model/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-model' diff --git a/packages/client/ui-model/tests/browser-plugin.spec.ts b/packages/client/ui-model/tests/browser-plugin.spec.ts index 422ada56b3..4c7a1430b1 100644 --- a/packages/client/ui-model/tests/browser-plugin.spec.ts +++ b/packages/client/ui-model/tests/browser-plugin.spec.ts @@ -8,7 +8,7 @@ * (and the reverse), the one-shared-state contract of the dual entry. * Scope disposal drops the directory (HMR safety). */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { createScope } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index dc02a09aa6..af55b2a5cc 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-models", "description": "Models settings and official-DeepSeek first-run routing over one live provider/settings/credential join", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-models" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -38,14 +45,14 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-schema-form": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-client-web-react": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-schema-form": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -60,7 +67,7 @@ "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-models/src/invariant.ts b/packages/client/ui-models/src/invariant.ts index c7c4996748..8b37c0774f 100644 --- a/packages/client/ui-models/src/invariant.ts +++ b/packages/client/ui-models/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-models' diff --git a/packages/client/ui-models/tests/apply.spec.ts b/packages/client/ui-models/tests/apply.spec.ts index 2842b94554..dc648e939b 100644 --- a/packages/client/ui-models/tests/apply.spec.ts +++ b/packages/client/ui-models/tests/apply.spec.ts @@ -1,5 +1,5 @@ /** Models section registration: slot declaration injection, the locale-following label thunk, and HMR recovery. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-models/tests/components.spec.tsx b/packages/client/ui-models/tests/components.spec.tsx index 09167b55cb..4d7086bd99 100644 --- a/packages/client/ui-models/tests/components.spec.tsx +++ b/packages/client/ui-models/tests/components.spec.tsx @@ -2,7 +2,7 @@ /** Section, setup-card, and hand-written editor behavior over a scripted wire face. */ import { act, cleanup, fireEvent, render, screen, waitFor, within } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import Schema from 'schemastery' +import Schema from '@deepseek-ai/schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' import { diff --git a/packages/client/ui-models/tests/invariant.spec.ts b/packages/client/ui-models/tests/invariant.spec.ts index 8f9622b599..05362ba8ea 100644 --- a/packages/client/ui-models/tests/invariant.spec.ts +++ b/packages/client/ui-models/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as ModelsInvariant from '@deepseek-ai/dsh-client-ui-models/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' import { ModelsSection } from '../src/client/ModelsSection.tsx' diff --git a/packages/client/ui-models/tests/provider-form.spec.tsx b/packages/client/ui-models/tests/provider-form.spec.tsx index be0f198df7..bd59ab0740 100644 --- a/packages/client/ui-models/tests/provider-form.spec.tsx +++ b/packages/client/ui-models/tests/provider-form.spec.tsx @@ -2,7 +2,7 @@ /** Model-list editing, endpoint interrogation, and hand-declared provider creation. */ import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' -import Schema from 'schemastery' +import Schema from '@deepseek-ai/schemastery' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import type { RpcResponse, SettingsNamespaceView } from '@deepseek-ai/dsh-client-connection/client' import { ModelsSection, providerCopy } from '../src/client/ModelsSection.tsx' diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index 4c90851cf7..41c3c7eb30 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-permission", "description": "Permission surfaces: a new-session default in General settings and a current-session /permission popup over the permissions projection", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-permission" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -39,17 +46,17 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-schema-form": "^0.0.1", - "@deepseek-ai/dsh-client-ui-command": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-permission": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-schema-form": "workspace:^", + "@deepseek-ai/dsh-client-ui-command": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-permission": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -65,7 +72,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-permission/src/invariant.ts b/packages/client/ui-permission/src/invariant.ts index 1c3f7d6500..2e531fe55f 100644 --- a/packages/client/ui-permission/src/invariant.ts +++ b/packages/client/ui-permission/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-permission' diff --git a/packages/client/ui-permission/tests/browser-plugin.spec.ts b/packages/client/ui-permission/tests/browser-plugin.spec.ts index 309298a64a..4575941b4a 100644 --- a/packages/client/ui-permission/tests/browser-plugin.spec.ts +++ b/packages/client/ui-permission/tests/browser-plugin.spec.ts @@ -8,7 +8,7 @@ * disposal removes the contribution (HMR safety). The same plugin registers * its Settings row and invalidates that row on host settings changes. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { SlotsService, type SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index c9aa3fd0f1..3ee6860ce5 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-plan", "description": "Plan-mode composer control: the conversation.input.plan seat over the plan projection and the /plan command channel", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-plan" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -38,15 +45,15 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-plan-mode": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-plan-mode": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -61,7 +68,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-plan/src/invariant.ts b/packages/client/ui-plan/src/invariant.ts index 82c9fc9376..acea37ddeb 100644 --- a/packages/client/ui-plan/src/invariant.ts +++ b/packages/client/ui-plan/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-plan' diff --git a/packages/client/ui-plan/tests/browser-plugin.spec.ts b/packages/client/ui-plan/tests/browser-plugin.spec.ts index e384ba5356..79d2903e93 100644 --- a/packages/client/ui-plan/tests/browser-plugin.spec.ts +++ b/packages/client/ui-plan/tests/browser-plugin.spec.ts @@ -5,7 +5,7 @@ * outcomes into null (admitted) or a user-visible failure line; teardown * empties the seat (HMR safety). */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index bb788f7069..05dbe8f822 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-primitives", "description": "Pure React atoms for the dsh web UI: controls, icons, markdown, and JSON inspectors (zero cordis)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-primitives" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -45,7 +52,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", @@ -53,7 +60,7 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 449d4fe717..fd6bd406e5 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -1,7 +1,7 @@ // Hover/focus label bubble (figma tooltip pill: dark plate, white text). -// TODO: interaction is a placeholder (horizontal overflow clamps, but there -// is no vertical flip on viewport collision and no arrow) — visuals and -// behavior get a proper pass later. +// TODO: interaction is a placeholder (horizontal overflow clamps and a +// vertical collision flips the bubble to the other side, but there is no +// arrow) — visuals and behavior get a proper pass later. // The anchor is the child element itself (cloneElement, no wrapper node), so // attaching a tooltip never changes the anchor's layout context. The bubble is // position:fixed and coordinates come from the anchor's rect at show time, so @@ -24,17 +24,21 @@ interface AnchorProps { onBlur?: FocusEventHandler | undefined } +type TooltipLabel = string | (() => string) + /** * Attach a hover/focus tooltip to an anchor element. - * @param props.label - bubble text. + * @param props.label - bubble text, or a resolver evaluated only while the bubble is visible. * @param props.side - placement relative to the anchor (default 'right'). * @param props.delayMs - hover delay in milliseconds; keyboard focus remains immediate. * @param props.disabled - suppress the bubble while true; the anchor renders identically so * toggling never remounts it (which would cut its CSS transitions). + * @param props.maxWidth - bubble width cap in pixels, for labels long enough that the default + * half-viewport cap would render a slab wider than the surface the anchor sits on. * @param props.children - a single anchor element; its own ref (callback or object) is forwarded alongside the tooltip's. * @returns the cloned anchor plus a fixed-position bubble while hovered/focused. */ -export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, children }: { label: string; side?: TooltipSide; delayMs?: number; disabled?: boolean; children: ReactElement }) { +export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, maxWidth, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; maxWidth?: number; children: ReactElement }) { const anchor = useRef(null) // React 18 keeps the element's ref outside props; forward it so wrapping an // anchor in Tooltip never silently severs the owner's ref. @@ -44,30 +48,53 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, if (typeof childRef === 'function') childRef(el) else if (childRef != null) (childRef as MutableRefObject).current = el }, [childRef]) - const [pos, setPos] = useState<{ x: number; y: number } | null>(null) + // The anchor's edges rather than final coordinates: a vertical flip has to + // re-derive the bubble's own top from the opposite edge. + const [pos, setPos] = useState<{ x: number; top: number; bottom: number } | null>(null) + // Where the bubble actually sits, which is the requested side until the + // viewport refuses it. + const [placement, setPlacement] = useState(side) const bubble = useRef(null) - // Horizontal viewport clamp: fixed positioning knows nothing about edges, so - // a centered bubble near the right edge would clip. Each measurement resets - // the base position before applying a direct style offset, allowing a shorter - // label or wider viewport to release a previous clamp without another render. + const resolvedLabel = pos === null + ? null + : typeof label === 'function' ? label() : label + const y = pos === null + ? 0 + : placement === 'right' + ? pos.top + (pos.bottom - pos.top) / 2 + : placement === 'top' ? pos.top - 8 : pos.bottom + 8 + const EDGE_MARGIN = 12 + // Viewport fit: fixed positioning knows nothing about edges, so a centered + // bubble near the right edge would clip and a long label under an anchor low + // on the page would run off the bottom. Horizontally the bubble slides back + // inside; vertically it flips to the opposite side, which is the only move + // that does not cover the anchor being read. Each measurement resets the base + // position first, so a shorter label or a larger viewport releases a previous + // adjustment without another render. useLayoutEffect(() => { if (pos === null) return - const clamp = () => { + const fit = () => { const el = bubble.current /* v8 ignore next -- pos is set only while the bubble is mounted. */ if (el === null) return - const EDGE_MARGIN = 12 el.style.left = `${pos.x}px` const r = el.getBoundingClientRect() let dx = 0 if (r.right > window.innerWidth - EDGE_MARGIN) dx = window.innerWidth - EDGE_MARGIN - r.right if (r.left + dx < EDGE_MARGIN) dx = EDGE_MARGIN - r.left el.style.left = `${pos.x + dx}px` + if (side === 'right') return + // Flip only into a side that genuinely fits, so an anchor with room on + // neither side keeps the requested placement instead of oscillating. + const fitsBelow = pos.bottom + 8 + r.height <= window.innerHeight - EDGE_MARGIN + const fitsAbove = pos.top - 8 - r.height >= EDGE_MARGIN + if (placement === 'bottom' && !fitsBelow && fitsAbove) setPlacement('top') + if (placement === 'top' && !fitsAbove && fitsBelow) setPlacement('bottom') } - clamp() - window.addEventListener('resize', clamp) - return () => { window.removeEventListener('resize', clamp) } - }, [label, pos]) + fit() + window.addEventListener('resize', fit) + return () => { window.removeEventListener('resize', fit) } + }, [placement, pos, resolvedLabel, side]) const showTimer = useRef | null>(null) // Hover and focus are independent triggers: the bubble hides only after // BOTH clear (hovering away from a focused anchor must not drop it). @@ -95,11 +122,10 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, /* v8 ignore next -- the ref is attached by event time: events fire on the cloned anchor. */ if (el === null) return const r = el.getBoundingClientRect() - setPos(side === 'right' - ? { x: r.right + 10, y: r.top + r.height / 2 } - : side === 'top' - ? { x: r.left + r.width / 2, y: r.top - 8 } - : { x: r.left + r.width / 2, y: r.bottom + 8 }) + // Every show starts from the requested side; the fit pass flips it only + // where this anchor's position demands it. + setPlacement(side) + setPos({ x: side === 'right' ? r.right + 10 : r.left + r.width / 2, top: r.top, bottom: r.bottom }) } const showAfterHoverDelay = () => { cancelShow() @@ -127,8 +153,14 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, onBlur: (e) => { children.props.onBlur?.(e); triggers.current.focus = false; hide() }, })} {pos !== null && ( - - {label} + + {resolvedLabel} )} diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 02f4913751..972e0ec14d 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -349,6 +349,35 @@ export const IconThinkOutline16 = ({ size = 16, className }: IconProps) => ( ) +/** ic_ds_agent_preset_outline_16 (figma extract): node interiors knock out to transparency via mask, so the glyph sits on any fill. */ +export const IconAgentPresetOutline16 = ({ size = 16, className }: IconProps) => ( + + + + + + + + + + + + +) + /** ic_ds_browse_outline_16 */ export const IconBrowseOutline16 = ({ size = 16, className }: IconProps) => ( diff --git a/packages/client/ui-primitives/src/invariant.ts b/packages/client/ui-primitives/src/invariant.ts index 5ce3411aff..a92fe97731 100644 --- a/packages/client/ui-primitives/src/invariant.ts +++ b/packages/client/ui-primitives/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-primitives' diff --git a/packages/client/ui-primitives/tests/icons.spec.tsx b/packages/client/ui-primitives/tests/icons.spec.tsx index fd15671b73..f6560a4cc1 100644 --- a/packages/client/ui-primitives/tests/icons.spec.tsx +++ b/packages/client/ui-primitives/tests/icons.spec.tsx @@ -16,8 +16,8 @@ const icons = Object.fromEntries( const iconNames = Object.keys(icons) describe('ic_ds_ icon set', () => { - it('exports the full icon set (46 deepsuite + 18 figma extracts + three product glyphs outside those sets)', () => { - expect(iconNames.length).toBe(67) + it('exports the full icon set (46 deepsuite + 19 figma extracts + three product glyphs outside those sets)', () => { + expect(iconNames.length).toBe(68) }) it.each(iconNames)('%s renders an svg with currentColor fills and no hardcoded palette', (name) => { diff --git a/packages/client/ui-primitives/tests/invariant.spec.ts b/packages/client/ui-primitives/tests/invariant.spec.ts index 72e5cb2f7c..122524380a 100644 --- a/packages/client/ui-primitives/tests/invariant.spec.ts +++ b/packages/client/ui-primitives/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as PrimitivesInvariant from '@deepseek-ai/dsh-client-ui-primitives/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 72b33ce12c..a884b58615 100644 --- a/packages/client/ui-primitives/tests/tooltip.spec.tsx +++ b/packages/client/ui-primitives/tests/tooltip.spec.tsx @@ -6,6 +6,27 @@ import { Tooltip } from '@deepseek-ai/dsh-client-ui-primitives' afterEach(cleanup) describe('Tooltip', () => { + it('resolves lazy labels only after the bubble becomes visible', () => { + vi.useFakeTimers() + try { + const label = vi.fn(() => 'Timing details') + render( + + + , + ) + expect(label).not.toHaveBeenCalled() + fireEvent.mouseEnter(screen.getByText('anchor')) + act(() => { vi.advanceTimersByTime(499) }) + expect(label).not.toHaveBeenCalled() + act(() => { vi.advanceTimersByTime(1) }) + expect(screen.getByRole('tooltip').textContent).toBe('Timing details') + expect(label).toHaveBeenCalledOnce() + } finally { + vi.useRealTimers() + } + }) + it('can delay pointer hover without delaying keyboard focus', () => { vi.useFakeTimers() try { @@ -74,6 +95,18 @@ describe('Tooltip', () => { const rect = (left: number, right: number): DOMRect => ({ left, right, top: 0, bottom: 20, width: right - left, height: 20, x: left, y: 0, toJSON: () => ({}) }) + it('caps the bubble width where the label would otherwise slab across the surface', () => { + render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + + // The stylesheet's half-viewport cap stays the default; this one overrides it. + expect(screen.getByRole('tooltip').style.maxWidth).toBe('360px') + }) + it('clamps a bubble overflowing the right viewport edge back inside', () => { const spy = vi.spyOn(Element.prototype, 'getBoundingClientRect').mockReturnValue(rect(900, 1100)) try { @@ -140,19 +173,88 @@ describe('Tooltip', () => { } }) + /** Anchor and bubble rects, so a placement test measures real room rather than jsdom's all-zero boxes. */ + const placed = (anchorTop: number, anchorBottom: number, bubbleHeight: number) => + vi.spyOn(Element.prototype, 'getBoundingClientRect').mockImplementation(function (this: Element) { + const [top, bottom] = this.getAttribute('role') === 'tooltip' + ? [0, bubbleHeight] + : [anchorTop, anchorBottom] + return { + left: 100, right: 200, top, bottom, width: 100, height: bottom - top, x: 100, y: top, toJSON: () => ({}), + } + }) + it('supports top placement for anchors at the viewport bottom', () => { - render( - - - , - ) - fireEvent.mouseEnter(screen.getByText('anchor')) - const bubble = screen.getByRole('tooltip') - expect(bubble.getAttribute('data-side')).toBe('top') - // jsdom rects are all-zero: top placement lands at the -8 gutter and the - // zero-width measured rect clamps left to the 12px edge margin. - expect(bubble.style.left).toBe('12px') - expect(bubble.style.top).toBe('-8px') + const spy = placed(700, 720, 20) + try { + render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + const bubble = screen.getByRole('tooltip') + // There is room above, so the requested side stands: the bubble's own + // top sits at the anchor's top less the 8px gutter. + expect(bubble.getAttribute('data-side')).toBe('top') + expect(bubble.style.top).toBe('692px') + expect(bubble.style.left).toBe('150px') + } finally { + spy.mockRestore() + } + }) + + it('flips a bottom bubble above an anchor with no room below', () => { + // jsdom's viewport is 768 tall: a 300px bubble under an anchor ending at + // 700 would run off, and there is room for it above. + const spy = placed(600, 700, 300) + try { + render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + const bubble = screen.getByRole('tooltip') + expect(bubble.getAttribute('data-side')).toBe('top') + expect(bubble.style.top).toBe('592px') + } finally { + spy.mockRestore() + } + }) + + it('flips a top bubble below an anchor with no room above', () => { + const spy = placed(10, 40, 100) + try { + render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + const bubble = screen.getByRole('tooltip') + expect(bubble.getAttribute('data-side')).toBe('bottom') + expect(bubble.style.top).toBe('48px') + } finally { + spy.mockRestore() + } + }) + + it('keeps the requested side when neither side fits', () => { + // A bubble taller than the viewport has no home; oscillating between the + // two would be worse than honouring the request. + const spy = placed(300, 400, 900) + try { + render( + + + , + ) + fireEvent.mouseEnter(screen.getByText('anchor')) + expect(screen.getByRole('tooltip').getAttribute('data-side')).toBe('bottom') + } finally { + spy.mockRestore() + } }) it('chains the anchor\'s own handlers ahead of the tooltip\'s', () => { diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index 874a634f7c..aafb3bf534 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-question", "description": "Web ask_user_question feature: host tool mount plus composer-takeover question UI", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-question" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -46,9 +53,9 @@ "react": "^18.2.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -58,7 +65,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-question/src/invariant.ts b/packages/client/ui-question/src/invariant.ts index 6a6e7ebb90..4da42322c3 100644 --- a/packages/client/ui-question/src/invariant.ts +++ b/packages/client/ui-question/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-question' diff --git a/packages/client/ui-question/tests/browser-plugin.spec.ts b/packages/client/ui-question/tests/browser-plugin.spec.ts index 01b077a29a..43e7673da9 100644 --- a/packages/client/ui-question/tests/browser-plugin.spec.ts +++ b/packages/client/ui-question/tests/browser-plugin.spec.ts @@ -6,7 +6,7 @@ * domain-face behavior is covered props-direct in question-composer.spec.tsx; * no renderer machinery here. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-question/tests/node-plugin.spec.ts b/packages/client/ui-question/tests/node-plugin.spec.ts index 4602ef0bed..29f137ebfe 100644 --- a/packages/client/ui-question/tests/node-plugin.spec.ts +++ b/packages/client/ui-question/tests/node-plugin.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' import ToolRegistry from '@deepseek-ai/dsh-tools' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 95166e6d66..e720ad810a 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-settings-general", "description": "Settings ownerless-copy and product onboarding plugin: the General section, shell trigger/header chrome content, settings dictionaries, and the versioned welcome notice", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-settings-general" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -40,18 +47,18 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-settings": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-client-web-react": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-settings": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-web-react": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -65,7 +72,7 @@ "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings-general/src/index.ts b/packages/client/ui-settings-general/src/index.ts index 18518c2835..0cea245db5 100644 --- a/packages/client/ui-settings-general/src/index.ts +++ b/packages/client/ui-settings-general/src/index.ts @@ -1,7 +1,7 @@ /** Host loader entry for the browser implementation exported from `./client`. */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { WELCOME_NOTICE_ACK_FIELD, WELCOME_NOTICE_SETTINGS_NAMESPACE, diff --git a/packages/client/ui-settings-general/src/invariant.ts b/packages/client/ui-settings-general/src/invariant.ts index c5917bd83b..adfd582ca6 100644 --- a/packages/client/ui-settings-general/src/invariant.ts +++ b/packages/client/ui-settings-general/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings-general' diff --git a/packages/client/ui-settings-general/tests/apply.spec.ts b/packages/client/ui-settings-general/tests/apply.spec.ts index ae06c22c03..4d7301faa1 100644 --- a/packages/client/ui-settings-general/tests/apply.spec.ts +++ b/packages/client/ui-settings-general/tests/apply.spec.ts @@ -1,5 +1,5 @@ /** Ownerless-copy registrations: the six seats, dictionaries, thunked labels, and HMR recovery. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-settings-general/tests/host.spec.ts b/packages/client/ui-settings-general/tests/host.spec.ts index 6434bc833a..e78d0e006a 100644 --- a/packages/client/ui-settings-general/tests/host.spec.ts +++ b/packages/client/ui-settings-general/tests/host.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { apply } from '../src/index.ts' diff --git a/packages/client/ui-settings-general/tests/invariant.spec.ts b/packages/client/ui-settings-general/tests/invariant.spec.ts index 59863a5794..343b09b547 100644 --- a/packages/client/ui-settings-general/tests/invariant.spec.ts +++ b/packages/client/ui-settings-general/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as GeneralInvariant from '@deepseek-ai/dsh-client-ui-settings-general/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 9383ca0205..3447573398 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-settings", "description": "Settings shell plugin: sidebar trigger, modal panel, feature sections, and an ordered full-page onboarding stage", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-settings" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -40,11 +47,11 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -57,7 +64,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react-dom": "~18.3.0", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 9163e68ba8..f1bd87e9af 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -205,11 +205,11 @@ background: var(--dsw-alias-interactive-bg-hover); } -/* Options area (figma Options 501:29983): pad (24,0,24,8), scrolls. */ +/* Options area (figma Options 501:29983): pad (24,0,24,24), scrolls. */ .options { flex: 1; min-height: 0; - padding: 0 24px 8px; + padding: 0 24px 24px; overflow-y: auto; } diff --git a/packages/client/ui-settings/src/client/SettingsRoot.tsx b/packages/client/ui-settings/src/client/SettingsRoot.tsx index 54e0e0dbb7..de00fa372e 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.tsx +++ b/packages/client/ui-settings/src/client/SettingsRoot.tsx @@ -14,7 +14,7 @@ import { useCallback, useEffect, useId, useRef, useState } from 'react' import clsx from 'clsx' import { - IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, IconThinkOutline16, + IconAgentPresetOutline16, IconCloseOutline16, IconDataOutline16, IconSettingsOutline16, } from '@deepseek-ai/dsh-client-ui-primitives' import type { SettingsRootComponentProps, SettingsSectionRow } from './contract/slots.ts' import css from './SettingsRoot.module.css' @@ -22,7 +22,7 @@ import css from './SettingsRoot.module.css' /** Nav glyph by section id; unknown ids fall back to the settings gear. */ function navIcon(id: string) { if (id === 'models') return - if (id === 'agent-presets') return + if (id === 'agent-presets') return return } diff --git a/packages/client/ui-settings/src/invariant.ts b/packages/client/ui-settings/src/invariant.ts index 53d7fb066a..f78b25e91c 100644 --- a/packages/client/ui-settings/src/invariant.ts +++ b/packages/client/ui-settings/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-settings' diff --git a/packages/client/ui-settings/tests/apply.spec.ts b/packages/client/ui-settings/tests/apply.spec.ts index 3133ad4d76..2e8243fa26 100644 --- a/packages/client/ui-settings/tests/apply.spec.ts +++ b/packages/client/ui-settings/tests/apply.spec.ts @@ -1,5 +1,5 @@ /** Settings shell registration: slot declaration injection, the ledger projections, and HMR recovery. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { apply, inject } from '@deepseek-ai/dsh-client-ui-settings/client' diff --git a/packages/client/ui-settings/tests/invariant.spec.ts b/packages/client/ui-settings/tests/invariant.spec.ts index c3474d5bdd..f9824921a6 100644 --- a/packages/client/ui-settings/tests/invariant.spec.ts +++ b/packages/client/ui-settings/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as SettingsInvariant from '@deepseek-ai/dsh-client-ui-settings/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index d40bcabcdb..73ce0817d7 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-sidebar" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -41,12 +48,12 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -58,7 +65,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css index a98d8ea26e..67310853a2 100644 --- a/packages/client/ui-sidebar/src/client/SidebarRoot.module.css +++ b/packages/client/ui-sidebar/src/client/SidebarRoot.module.css @@ -84,7 +84,7 @@ gap: 8px; height: 60px; padding: 8px 0 8px 4px; - margin-bottom: 16px; + margin-bottom: 8px; box-sizing: border-box; overflow: hidden; } @@ -157,8 +157,8 @@ color: var(--dsw-alias-label-primary); } -/* New Session: 38px capsule (figma 133:7634); collapsed it renders as the - rail's plain icon control. */ +/* New Session: 38px bar, 12px radius (figma 133:7634 geometry, squared-off + corners); collapsed it renders as the rail's plain icon control. */ .newSession { flex: none; display: flex; @@ -170,7 +170,7 @@ margin: 0 2px 20px; /* bottom: former headerBlock padBottom 12 + root gap 8 */ box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); - border-radius: 24px; + border-radius: 12px; background: var(--dsw-alias-button-elevated-fill); color: var(--dsw-alias-label-primary); font-size: 14px; diff --git a/packages/client/ui-sidebar/src/invariant.ts b/packages/client/ui-sidebar/src/invariant.ts index 52d69c5ce2..94e26021a8 100644 --- a/packages/client/ui-sidebar/src/invariant.ts +++ b/packages/client/ui-sidebar/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-sidebar' diff --git a/packages/client/ui-sidebar/tests/apply.spec.tsx b/packages/client/ui-sidebar/tests/apply.spec.tsx index ccd997be76..31fcddfe9f 100644 --- a/packages/client/ui-sidebar/tests/apply.spec.tsx +++ b/packages/client/ui-sidebar/tests/apply.spec.tsx @@ -1,5 +1,5 @@ /** Sidebar shell slot registration and its plain runtime/layout callbacks. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-sidebar/tests/invariant.spec.ts b/packages/client/ui-sidebar/tests/invariant.spec.ts index c524606dd5..11a8d66a08 100644 --- a/packages/client/ui-sidebar/tests/invariant.spec.ts +++ b/packages/client/ui-sidebar/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as SidebarInvariant from '@deepseek-ai/dsh-client-ui-sidebar/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 68a2dc2667..5734767d7b 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-skill" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -39,15 +46,15 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-tool": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-tool": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -62,7 +69,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-skill/src/invariant.ts b/packages/client/ui-skill/src/invariant.ts index 9246466cd1..718a9586a1 100644 --- a/packages/client/ui-skill/src/invariant.ts +++ b/packages/client/ui-skill/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-skill' diff --git a/packages/client/ui-skill/tests/browser-plugin.spec.ts b/packages/client/ui-skill/tests/browser-plugin.spec.ts index 5e143c0b96..844333dd81 100644 --- a/packages/client/ui-skill/tests/browser-plugin.spec.ts +++ b/packages/client/ui-skill/tests/browser-plugin.spec.ts @@ -13,7 +13,7 @@ * projections. Direct driving is deliberate: this spec owns only the * source's own contract. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-slash/package.json b/packages/client/ui-slash/package.json index 01e48dcdf6..485052ae5f 100644 --- a/packages/client/ui-slash/package.json +++ b/packages/client/ui-slash/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-slash", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-slash" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -40,12 +47,12 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -56,7 +63,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-slash/src/client/index.ts b/packages/client/ui-slash/src/client/index.ts index bcc20a8679..54f7a96dda 100644 --- a/packages/client/ui-slash/src/client/index.ts +++ b/packages/client/ui-slash/src/client/index.ts @@ -27,7 +27,7 @@ export type { export type { DetectTrigger, ExactMatch, MenuEvent, MenuReduce, MenuState, TriggerHit } from '../core/contract.ts' export type { SlashServiceContract } from './contract.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The outward face only; the concrete service stays inside this plugin. */ slash: import('./contract.ts').SlashServiceContract diff --git a/packages/client/ui-slash/src/client/service.ts b/packages/client/ui-slash/src/client/service.ts index d9af10e887..35e6153a9b 100644 --- a/packages/client/ui-slash/src/client/service.ts +++ b/packages/client/ui-slash/src/client/service.ts @@ -5,8 +5,8 @@ * {@link SlashController}; the service only registers sources, resolves * controllers by session scope, and relays roster changes. */ -import { Service } from 'cordis' -import type { Context } from 'cordis' +import { Service } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ClientContext, ISessions, SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type { SlashSource } from '../types.ts' import { SlashController } from './controller.ts' diff --git a/packages/client/ui-slash/src/invariant.ts b/packages/client/ui-slash/src/invariant.ts index a83b4841a1..fb9102be3f 100644 --- a/packages/client/ui-slash/src/invariant.ts +++ b/packages/client/ui-slash/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-slash' diff --git a/packages/client/ui-slash/src/types.ts b/packages/client/ui-slash/src/types.ts index c62ca520d7..1266bc15da 100644 --- a/packages/client/ui-slash/src/types.ts +++ b/packages/client/ui-slash/src/types.ts @@ -220,7 +220,7 @@ export interface InsertTextRequest { readonly span: TokenSpan } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { /** * Applies one command claim to the scoped Input. Dispatched with the diff --git a/packages/client/ui-slash/tests/apply.spec.ts b/packages/client/ui-slash/tests/apply.spec.ts index c8d65f10c8..b447cd12d0 100644 --- a/packages/client/ui-slash/tests/apply.spec.ts +++ b/packages/client/ui-slash/tests/apply.spec.ts @@ -4,7 +4,7 @@ * registration follows the slot declaration, resolves the per-session controller from the slot's * sessionId, and unregisters on fiber teardown. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' import { usePinnedBrowserLanguages } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/ui-slash/tests/service.spec.ts b/packages/client/ui-slash/tests/service.spec.ts index 143f6e6ddf..c642ec8001 100644 --- a/packages/client/ui-slash/tests/service.spec.ts +++ b/packages/client/ui-slash/tests/service.spec.ts @@ -7,7 +7,7 @@ * scope-birth roster warm — is SlashController behavior, tested on a real * session scope (createScope). */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createScope, scopeOf } from '@deepseek-ai/dsh-client-runtime/client' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 9c459fd0f8..85a8886519 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-slots", "description": "Slot registry pure core: SlotMap declaration merging, single register composition API, four-share props types, store-seat types, renderer install seam", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-slots" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -22,7 +29,7 @@ "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", @@ -30,7 +37,7 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/ui-slots/src/invariant.ts b/packages/client/ui-slots/src/invariant.ts index de9ea2c511..d3cd66c5ed 100644 --- a/packages/client/ui-slots/src/invariant.ts +++ b/packages/client/ui-slots/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-slots' diff --git a/packages/client/ui-slots/tests/invariant.spec.ts b/packages/client/ui-slots/tests/invariant.spec.ts index 3b5740924f..72d2f32fc8 100644 --- a/packages/client/ui-slots/tests/invariant.spec.ts +++ b/packages/client/ui-slots/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as SlotsInvariant from '@deepseek-ai/dsh-client-ui-slots/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 43a9c35a42..75e5b53af7 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-subagent" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -43,16 +50,16 @@ "react": "^18.2.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slash": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-token-meter": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slash": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-client-locale": "workspace:^", @@ -66,7 +73,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css index fc3ddfea46..75f0040cf6 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.module.css @@ -54,7 +54,6 @@ max-height: min(560px, calc(100vh - 140px)); padding: 4px; overflow: auto; - border: 1px solid var(--dsw-alias-border-l2); border-radius: 12px; background: var(--dsw-specific-menu); --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); diff --git a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx index 3a6ace14a5..2a730245e7 100644 --- a/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx +++ b/packages/client/ui-subagent/src/client/SubagentCatalogAction.tsx @@ -519,8 +519,14 @@ export function SubagentCatalogAction({ observedCatalogs.current.clear() }, []) + // Visibility needs evidence of children (entries, summary-known descendants, + // or a failed load worth retrying). A bare loading catalog is not evidence: + // selecting any session schedules a refresh whose loading snapshot would + // otherwise flash the action in and out on childless sessions. const visible = presentedCatalog !== undefined - && (presentedCatalog.state !== 'ready' || presentedCatalog.entries.length > 0) + && (presentedCatalog.state === 'error' + || presentedCatalog.entries.length > 0 + || descendantCount > 0) useEffect(() => { if (visible || !open) return setOpen(false) diff --git a/packages/client/ui-subagent/src/invariant.ts b/packages/client/ui-subagent/src/invariant.ts index 645f88c9b6..b96fefa21c 100644 --- a/packages/client/ui-subagent/src/invariant.ts +++ b/packages/client/ui-subagent/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-subagent' diff --git a/packages/client/ui-subagent/tests/browser-plugin.spec.ts b/packages/client/ui-subagent/tests/browser-plugin.spec.ts index 78d04489ab..426dadaa60 100644 --- a/packages/client/ui-subagent/tests/browser-plugin.spec.ts +++ b/packages/client/ui-subagent/tests/browser-plugin.spec.ts @@ -11,7 +11,7 @@ * projections. Direct driving is deliberate: this spec owns only the * source's own contract. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { SlotsService, type ConversationSnapshot, type SessionId, type SessionListState, diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index c300e423e6..ebc140405e 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -61,6 +61,7 @@ function props( }, current: PARENT, phase: 'ready', subagentsByParent: value === undefined ? nested : { [PARENT]: value, ...nested }, + tasksBySession: {}, currentAddress: undefined, } satisfies SessionListState function useSessions(select: (snapshot: SessionListState) => T): T { @@ -522,22 +523,23 @@ describe('SubagentCatalogAction', () => { expect(staleEmpty.openChild).not.toHaveBeenCalled() }) - it('renders empty loading and fallback error states without focusable rows', async () => { + it('hides a bare loading catalog and keeps the error fallback without focusable rows', async () => { + // Selecting any session schedules a catalog refresh; a loading snapshot + // with no other evidence of children must not flash the action in. const loading = props(catalog({ entries: [], state: 'loading' })) const view = render() - const trigger = screen.getByRole('button', { name: /0 个子代理/ }) - fireEvent.click(trigger) - expect(screen.getByText('正在加载子代理…')).toBeTruthy() - fireEvent.keyDown(trigger, { key: 'ArrowDown' }) - await Promise.resolve() - expect(screen.getByRole('tree')).toBeTruthy() - fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' }) + expect(screen.queryByRole('button')).toBeNull() view.unmount() const failed = props(catalog({ entries: [], state: 'error', error: null })) render() - fireEvent.click(screen.getByRole('button', { name: /0 个子代理/ })) + const trigger = screen.getByRole('button', { name: /0 个子代理/ }) + fireEvent.click(trigger) expect(screen.getByText('无法加载子代理')).toBeTruthy() + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + await Promise.resolve() + expect(screen.getByRole('tree')).toBeTruthy() + fireEvent.keyDown(screen.getByRole('tree'), { key: 'ArrowUp' }) }) it('navigates from outside the tree and tolerates a deferred focus after unmount', async () => { diff --git a/packages/self-modification/repository-plugin/README.i18n.yaml b/packages/client/ui-task/README.i18n.yaml similarity index 53% rename from packages/self-modification/repository-plugin/README.i18n.yaml rename to packages/client/ui-task/README.i18n.yaml index 6b0e958c2c..39f800e04b 100644 --- a/packages/self-modification/repository-plugin/README.i18n.yaml +++ b/packages/client/ui-task/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 packages/self-modification/repository-plugin/README.md -README.md: 666f00e02b9ab33bff348df6b4ff90e3f3bfecc7 -README.zh.md: b09f68bc17a4eb08df6ecbb3782e14bf26fb7d7f +# pnpm run verify-translation-pairing --write packages/client/ui-task/README.md +README.md: a1430db55c7519c612e5d39de4627c750976d2d5 +README.zh.md: 5e29c939806f9a6500e78324c5a3322b6fd94539 diff --git a/packages/client/ui-task/README.md b/packages/client/ui-task/README.md new file mode 100644 index 0000000000..a1430db55c --- /dev/null +++ b/packages/client/ui-task/README.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-client-ui-task + +English | [中文](README.zh.md) + +Web background-task feature owner: contributes one entry to `conversation.session.header.actions` listing the `ctx.tasks` records this session can see. The data arrives entirely through the `tasksBySession` list mirror that [`dsh-client-runtime`](../runtime/README.md) folds from `session/tasks` frames, so this package issues no RPC and holds no state beyond popover visibility. + +The trigger renders only when the session has at least one task, so an ordinary conversation never grows a control for a capability it is not using. Its badge counts `running` plus `stopping` and is omitted at zero, leaving a session that holds only finished tasks a quiet entry point into its history rather than one advertising a count of nothing. The popover is a flat list: live rows first by `startedAt` ascending, then settled rows by `finishedAt` descending, with a same-millisecond tie broken on start order so the host's map iteration never decides it. A row shows the producer kind, the label, a status marker, the producer's `detail` in place of the generic status word once it has one, and an elapsed duration. That duration advances once per second while the row is live and freezes at `finishedAt`; the clock runs only while an open list holds something that moves. A settled row missing `finishedAt` reads as zero rather than as a negative figure, and a duration past an hour stays in hours rather than growing a day vocabulary no producer currently reaches. + +Settled rows stay visible and de-emphasized until the registry drops them at owner disposal. They are in the snapshot, a failed task's `detail` is the only place its failure is legible, and filtering them out here is work the output and cancellation phases would undo. A running one-shot background subagent therefore appears both here and in the [subagent catalog](../ui-subagent/README.md): the catalog navigates into the child's transcript, while this list is the only handle a future cancellation can attach to. + +Escape closes the list and returns focus to the trigger, as does a pointer press outside it. The last task disappearing closes the list before the control unmounts, so focus never vanishes from a removed node. Styling uses tokens only; copy goes through the package's own `task` locale namespace. The behavior is specified by the [Web background-task display Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md). + +## Model Experience + +None, as this package renders host-computed registry state for a human and touches no prompt, message, schema, stream, or tool result. The model's own view of the same tasks stays with [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md). + +#### KV Cache effect + +None; the package never assembles or sends provider requests. + +## Known Limitations and Deferred Work + +- **Rows are read-only** — a task's streamed output and a human-initiated cancellation are separate phases. Cancellation additionally owes a model-facing decision the seam does not answer today: `kill()` marks terminal delivery reported, so an interrupt written against the current contract would leave the model believing its task is still running. +- **The list is not the registry's own set** — it shows what one session can see through the wire view, so a task owned by another session never appears here, and a process restart empties the list while the transcript keeps the `run_in_background` cards that started those tasks. An unowned task (one started without a live `Agent`) is the opposite case: it reaches every session's list, matching what `list(caller)` reports to every caller. diff --git a/packages/client/ui-task/README.zh.md b/packages/client/ui-task/README.zh.md new file mode 100644 index 0000000000..5e29c93980 --- /dev/null +++ b/packages/client/ui-task/README.zh.md @@ -0,0 +1,24 @@ +# @deepseek-ai/dsh-client-ui-task + +[English](README.md) | 中文 + +Web 后台任务特性的归属方:向 `conversation.session.header.actions` 贡献一个条目,列出当前会话可见的 `ctx.tasks` 记录。数据完全来自 [`dsh-client-runtime`](../runtime/README.md) 从 `session/tasks` 帧折叠出的 `tasksBySession` 列表镜像,因此本包不发任何 RPC,除弹层开合外不持有任何状态。 + +只有当会话至少有一个任务时才渲染触发器,普通对话不会因为一项未被使用的能力而长出控件。角标计数为 `running` 加 `stopping`,为零时省略,这样只剩已完成任务的会话保留一个安静的历史入口,而不是宣告一个「零」。弹层是一个扁平列表:活跃行在前按 `startedAt` 升序,随后终态行按 `finishedAt` 降序;毫秒相同的并列按启动顺序打破,宿主的 map 迭代顺序永远不参与决定。一行显示生产者 kind、label、状态标记、生产者一旦给出 `detail` 就取代通用状态词的那段文字,以及已耗时。该耗时在活跃时每秒推进,并在 `finishedAt` 冻结;只有当打开的列表里确实有会动的东西时时钟才运行。缺少 `finishedAt` 的终态行读作零而不是负数,超过一小时的耗时停留在小时单位,不会长出任何生产者目前都到不了的「天」词汇。 + +终态行保持可见并弱化,直到注册表在 owner 销毁时把它们丢掉。它们本就在快照里,失败任务的 `detail` 是其失败唯一可读之处,在这里过滤掉它们是输出与中断两期要推翻的工作。因此一个运行中的一次性后台 subagent 会同时出现在这里和 [subagent 目录](../ui-subagent/README.md)里:目录负责进入子会话的 transcript,而这个列表是将来中断能力唯一可能附着的句柄。 + +Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理。最后一个任务消失时先关闭列表再卸载控件,焦点因此不会从一个被移除的节点上凭空消失。样式只用 token;文案走本包自己的 `task` locale 命名空间。行为由 [Web 后台任务展示 Agent Note](../../../.agents/notes/implemented/feature/2026-08-08-web-background-task-display.md) 规定。 + +## Model Experience + +无,因为本包为人类渲染宿主计算出的注册表状态,不触及 prompt、消息、schema、流或工具结果。模型对同一批任务的视角仍属于 [`dsh-tool-tasks`](../../tasks/tool-tasks/README.md)。 + +#### KV Cache effect + +无;本包从不组装或发送 provider 请求。 + +## Known Limitations and Deferred Work + +- **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 目前没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。 +- **列表不等于注册表自己的集合** —— 它展示的是「一个会话通过线路视图能看到什么」,所以别的会话拥有的任务在这里永远不出现;而进程重启会清空列表,transcript 里启动这些任务的 `run_in_background` 卡片却还在。无主任务(在没有活体 `Agent` 时启动的)是反过来的情形:它会进入每一个会话的列表,与 `list(caller)` 对每个调用方的报告一致。 diff --git a/packages/client/ui-task/package.json b/packages/client/ui-task/package.json new file mode 100644 index 0000000000..c8354f8f6c --- /dev/null +++ b/packages/client/ui-task/package.json @@ -0,0 +1,77 @@ +{ + "name": "@deepseek-ai/dsh-client-ui-task", + "description": "Session-header background-task list: live registry state mirrored from session/tasks frames", + "version": "0.0.1-rc.1", + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dsh": { + "client": { + "inject": [ + "@deepseek-ai/dsh-client-locale", + "@deepseek-ai/dsh-client-runtime", + "@deepseek-ai/dsh-client-ui-conversation", + "@deepseek-ai/dsh-client-ui-primitives" + ], + "platform": "web" + } + }, + "scripts": { + "bundle": "tsdown", + "watch": "tsdown --watch" + }, + "license": "BSD-3-Clause", + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-task" + }, + "publishConfig": { + "access": "restricted" + }, + "dependencies": { + "react": "^18.2.0" + }, + "peerDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-test-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@types/react": "~18.3.1", + "@deepseek-ai/cordis": "workspace:^" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/client.js", + "lib/types/**/*.d.ts" + ] +} diff --git a/packages/client/ui-task/src/client/TaskListAction.module.css b/packages/client/ui-task/src/client/TaskListAction.module.css new file mode 100644 index 0000000000..b6ac05aa31 --- /dev/null +++ b/packages/client/ui-task/src/client/TaskListAction.module.css @@ -0,0 +1,125 @@ +.root { + position: relative; +} + +.trigger { + display: inline-flex; + align-items: center; + gap: 3px; + min-height: 28px; + padding: 3px 2px; + border: 0; + border-radius: 6px; + background: transparent; + color: var(--dsw-alias-label-tertiary); + font-size: 12px; + line-height: 18px; + cursor: pointer; +} + +.trigger:hover, +.trigger:focus-visible { + color: var(--dsw-alias-label-secondary); +} + +.trigger svg { + transition: transform 120ms ease; +} + +.triggerOpen { + transform: rotate(180deg); +} + +.triggerDot { + flex: none; +} + +.count { + margin: 0 5px; +} + +.menu { + position: absolute; + top: calc(100% + 5px); + left: 0; + z-index: 100; + box-sizing: border-box; + display: flex; + flex-direction: column; + gap: 1px; + width: 336px; + max-width: min(400px, calc(100vw - 32px)); + max-height: min(420px, calc(100vh - 140px)); + margin: 0; + padding: 4px; + overflow: auto; + list-style: none; + border: 1px solid var(--dsw-alias-border-l2); + border-radius: 12px; + background: var(--dsw-specific-menu); + --dsh-scrollbar-thumb: var(--dsw-alias-scrollbar-bg-l2); + --dsh-scrollbar-thumb-hover: var(--dsw-alias-scrollbar-hover-l2); + box-shadow: var(--dsw-shadow-lv3); +} + +.row { + display: flex; + align-items: center; + gap: 8px; + box-sizing: border-box; + width: 100%; + min-height: 32px; + padding: 6px 8px; + border-radius: 8px; + color: var(--dsw-alias-label-primary); + font-size: 13px; + line-height: 18px; +} + +.rowSettled { + color: var(--dsw-alias-label-tertiary); +} + +.rowDot { + flex: none; +} + +.kind { + flex: none; + padding: 0 6px; + border-radius: 5px; + background: var(--dsw-alias-fill-l2); + color: var(--dsw-alias-label-secondary); + font-size: 11px; + line-height: 18px; +} + +.label { + flex: 1; + min-width: 0; + overflow: hidden; + font-family: var(--dsw-font-mono); + white-space: nowrap; + text-overflow: ellipsis; +} + +.status, +.duration { + flex: none; + color: var(--dsw-alias-label-tertiary); + font-size: 11px; + line-height: 18px; +} + +/* A failed task's detail is the producer's raw error text, so it has no bound; + without this it widens the row past the menu instead of eliding like .label. */ +.status { + max-width: 40%; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.duration { + font-variant-numeric: tabular-nums; +} diff --git a/packages/client/ui-task/src/client/TaskListAction.tsx b/packages/client/ui-task/src/client/TaskListAction.tsx new file mode 100644 index 0000000000..ede2eca377 --- /dev/null +++ b/packages/client/ui-task/src/client/TaskListAction.tsx @@ -0,0 +1,192 @@ +import { useEffect, useMemo, useRef, useState, type KeyboardEvent } from 'react' +import type { TaskView } from '@deepseek-ai/dsh-client-runtime/client' +import { IconChevronDownOutline14, StateDot, type StateDotState } from '@deepseek-ai/dsh-client-ui-primitives' +import type { PropsLocale, PropsRuntime, TranslateNS } from '@deepseek-ai/dsh-client-ui-slots' +import { NS } from './locales.ts' +import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' +import css from './TaskListAction.module.css' + +/** Full props for the session-header background-task action. */ +export type TaskListActionProps = + PropsRuntime<'conversation.session.header.actions'> & PropsLocale + +/** Stable empty list so a session with no tasks keeps one array identity. */ +const NO_TASKS: readonly TaskView[] = [] + +/** A task the registry still holds open, and whose duration therefore ticks. */ +function isLive(task: TaskView): boolean { + return task.status === 'running' || task.status === 'stopping' +} + +/** Closed-union exhaustiveness fence for the wire status set. */ +/* v8 ignore next 3 -- closed-union backstop; only reached if a status is forged */ +function assertNever(value: never): never { + throw new Error(`unhandled task status: ${JSON.stringify(value)}`) +} + +/** + * Status marker semantics. `stopping` and `killed` share the attention color: + * both mean the work ended (or is ending) on request rather than on its own. + */ +function dotState(status: TaskView['status']): StateDotState { + switch (status) { + case 'running': return 'ongoing' + case 'stopping': return 'warning' + case 'completed': return 'done' + case 'killed': return 'warning' + case 'failed': return 'error' + /* v8 ignore next -- closed wire status union */ + default: return assertNever(status) + } +} + +/** Human status word for the row and its accessible name. */ +function statusLabel(status: TaskView['status'], t: TranslateNS): string { + switch (status) { + case 'running': return t('status.running') + case 'stopping': return t('status.stopping') + case 'completed': return t('status.completed') + case 'killed': return t('status.killed') + case 'failed': return t('status.failed') + /* v8 ignore next -- closed wire status union */ + default: return assertNever(status) + } +} + +/** + * Elapsed time in at most two adjacent units. A background task that outlives + * an hour is already exceptional, so hours is the widest unit — beyond that the + * figure stays in hours rather than growing a day/month vocabulary no producer + * currently reaches. + */ +function formatDuration(elapsedMs: number, t: TranslateNS): string { + const total = Math.max(0, Math.floor(elapsedMs / 1_000)) + const seconds = total % 60 + const minutes = Math.floor(total / 60) % 60 + const hours = Math.floor(total / 3_600) + if (hours > 0) return t('duration.hours', { hours, minutes }) + if (minutes > 0) return t('duration.minutes', { minutes, seconds }) + return t('duration.seconds', { seconds }) +} + +/** + * Live rows first in start order, then settled rows newest-first. Two tasks + * that settled in the same millisecond fall back to start order, so the sort + * never depends on the host's map iteration. + */ +function ordered(tasks: readonly TaskView[]): TaskView[] { + return [...tasks].sort((left, right) => { + const liveLeft = isLive(left) + if (liveLeft !== isLive(right)) return liveLeft ? -1 : 1 + if (liveLeft) return left.startedAt - right.startedAt + const finished = (right.finishedAt ?? right.startedAt) - (left.finishedAt ?? left.startedAt) + return finished !== 0 ? finished : left.startedAt - right.startedAt + }) +} + +/** + * Session-header entry point for this session's background tasks. It renders + * nothing at all until the session has at least one task, so an ordinary + * conversation never grows a control for a capability it is not using. + * @param props - runtime slot currency plus the namespace translator. + * @returns the trigger and its popover list, or null when there is nothing to show. + */ +export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProps) { + const tasks = useSessions(state => state.tasksBySession[sessionId]) ?? NO_TASKS + const [open, setOpen] = useState(false) + const [now, setNow] = useState(() => Date.now()) + const rootRef = useRef(null) + const triggerRef = useRef(null) + + const rows = useMemo(() => ordered(tasks), [tasks]) + const liveCount = useMemo(() => tasks.filter(isLive).length, [tasks]) + + useEffect(() => { + if (!open) return + const closeOutside = (event: PointerEvent): void => { + if (event.target instanceof Node && !rootRef.current?.contains(event.target)) { + setOpen(false) + } + } + document.addEventListener('pointerdown', closeOutside) + return () => { document.removeEventListener('pointerdown', closeOutside) } + }, [open]) + + // The clock only runs while an open list is showing something that moves. + useEffect(() => { + if (!open || liveCount === 0) return + setNow(Date.now()) + const timer = setInterval(() => { setNow(Date.now()) }, 1_000) + return () => { clearInterval(timer) } + }, [open, liveCount]) + + // The last task disappearing removes this control; close first so focus does + // not vanish from an unmounting node. + useEffect(() => { + if (tasks.length === 0 && open) setOpen(false) + }, [tasks.length, open]) + + if (tasks.length === 0) return null + + const countKey = liveCount > 0 + ? (liveCount === 1 ? 'count.live.one' : 'count.live.other') + : (tasks.length === 1 ? 'count.idle.one' : 'count.idle.other') + const countLabel = t(countKey, { count: liveCount > 0 ? liveCount : tasks.length }) + + const onKeyDown = (event: KeyboardEvent): void => { + if (event.key !== 'Escape' || !open) return + event.preventDefault() + setOpen(false) + triggerRef.current?.focus() + } + + return ( +
+ + {open + ? ( +
    + {rows.map((task) => { + const live = isLive(task) + const elapsed = live ? now - task.startedAt : (task.finishedAt ?? task.startedAt) - task.startedAt + const duration = formatDuration(elapsed, t) + const status = statusLabel(task.status, t) + return ( +
  • + + {task.kind} + {task.label} + {task.detail ?? status} + + {duration} + +
  • + ) + })} +
+ ) + : null} +
+ ) +} diff --git a/packages/client/ui-task/src/client/index.ts b/packages/client/ui-task/src/client/index.ts new file mode 100644 index 0000000000..792b5f39d4 --- /dev/null +++ b/packages/client/ui-task/src/client/index.ts @@ -0,0 +1,40 @@ +/** + * Background-task plugin, browser half: contributes one session-header action + * that renders this session's `ctx.tasks` records. The data arrives entirely + * through the `tasksBySession` list mirror, so the plugin issues no RPC and + * holds no state of its own beyond popover visibility. + */ +import type { ClientContext } from '@deepseek-ai/dsh-client-runtime/client' +import { TaskListAction } from './TaskListAction.tsx' +import type {} from '@deepseek-ai/dsh-client-locale/client' +import { en, NS, zh, type TaskKey } from './locales.ts' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** Background-task list copy. */ + 'task': TaskKey + } +} + +export type { TaskListActionProps } from './TaskListAction.tsx' + +/** Required services for locale registration and header-slot contribution. */ +export const inject = ['sessions', 'slots', 'locale'] + +/** + * Client plugin body: register the dictionaries and the header action. + * @param ctx - client root context. + */ +export function apply(ctx: ClientContext): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-task: dictionaries') + ctx.slots.inject( + 'conversation.session.header.actions', + () => ctx.slots.register({ + name: 'conversation.session.header.actions', + id: 'task-list', + // After the subagent catalog: session lineage reads before process work. + order: 20, + locale: NS, + }, TaskListAction), + ) +} diff --git a/packages/client/ui-task/src/client/locales.ts b/packages/client/ui-task/src/client/locales.ts new file mode 100644 index 0000000000..8c03da56eb --- /dev/null +++ b/packages/client/ui-task/src/client/locales.ts @@ -0,0 +1,45 @@ +/** `task` namespace dictionaries. */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'task' + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh = { + 'count.live.one': '{count} 个后台任务运行中', + 'count.live.other': '{count} 个后台任务运行中', + 'count.idle.one': '{count} 个后台任务', + 'count.idle.other': '{count} 个后台任务', + 'list.aria': '后台任务', + 'status.running': '运行中', + 'status.stopping': '正在停止', + 'status.completed': '已完成', + 'status.killed': '已取消', + 'status.failed': '已失败', + 'duration.seconds': '{seconds}秒', + 'duration.minutes': '{minutes}分{seconds}秒', + 'duration.hours': '{hours}小时{minutes}分', + 'duration.title.live': '已运行 {duration}', + 'duration.title.done': '耗时 {duration}', +} as const + +/** English dictionary, key-identical to the Chinese source of truth. */ +export const en: Record = { + 'count.live.one': '{count} background task running', + 'count.live.other': '{count} background tasks running', + 'count.idle.one': '{count} background task', + 'count.idle.other': '{count} background tasks', + 'list.aria': 'Background tasks', + 'status.running': 'running', + 'status.stopping': 'stopping', + 'status.completed': 'completed', + 'status.killed': 'cancelled', + 'status.failed': 'failed', + 'duration.seconds': '{seconds}s', + 'duration.minutes': '{minutes}m {seconds}s', + 'duration.hours': '{hours}h {minutes}m', + 'duration.title.live': 'Running for {duration}', + 'duration.title.done': 'Took {duration}', +} + +/** Key domain of the `task` namespace (zh is the source of truth). */ +export type TaskKey = keyof typeof zh diff --git a/packages/client/ui-task/src/css-modules.d.ts b/packages/client/ui-task/src/css-modules.d.ts new file mode 100644 index 0000000000..bc5e482353 --- /dev/null +++ b/packages/client/ui-task/src/css-modules.d.ts @@ -0,0 +1,6 @@ +declare module '*.module.css' { + const classes: Record + export default classes +} + +declare module '*.css' diff --git a/packages/client/ui-task/src/index.ts b/packages/client/ui-task/src/index.ts new file mode 100644 index 0000000000..3962e1b113 --- /dev/null +++ b/packages/client/ui-task/src/index.ts @@ -0,0 +1,9 @@ +/** + * Background-task list plugin, node half. Pure UI plugin: the empty apply + * exists so the plugin appears in the host cordis.yml / Loader; the browser + * half ships via exports["./client"], discovered through the package.json + * dshClient declaration. + */ + +/** Host plugin body — no host-side behavior for this source plugin. */ +export function apply(): void {} diff --git a/packages/self-modification/repository-plugin/src/invariant.ts b/packages/client/ui-task/src/invariant.ts similarity index 53% rename from packages/self-modification/repository-plugin/src/invariant.ts rename to packages/client/ui-task/src/invariant.ts index 410e8bf69e..cfdad3c1f2 100644 --- a/packages/self-modification/repository-plugin/src/invariant.ts +++ b/packages/client/ui-task/src/invariant.ts @@ -1,22 +1,24 @@ /** - * Package-owned invariant companion for `@deepseek-ai/dsh-repository-plugin`. - * @module @deepseek-ai/dsh-repository-plugin/invariant + * Package-owned invariant companion for `@deepseek-ai/dsh-client-ui-task`. + * @module @deepseek-ai/dsh-client-ui-task/invariant */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' -const PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin' +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-task' /** Cordis companion plugin name. */ -export const name = 'repository-plugin-invariant' +export const name = 'client-ui-task-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. + * No runtime invariant: this package is a read-only projection of the + * `tasksBySession` mirror onto one header slot entry. It emits no cordis + * events, owns no cross-plugin mutable state, and its single slot registration + * proves disposal through the HMR-safety spec. */ const install: InvariantInstaller = () => {} diff --git a/packages/client/ui-task/tests/browser-plugin.spec.ts b/packages/client/ui-task/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..142668387f --- /dev/null +++ b/packages/client/ui-task/tests/browser-plugin.spec.ts @@ -0,0 +1,91 @@ +/** + * ui-task plugin halves: the browser entry's dictionary and header-slot + * registrations against the real SlotsService (with fiber teardown proving + * removal — HMR safety), the inert node entry, and the invariant companion's + * ownership reservation. + */ +import { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { apply as applyLocale, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import { apply, inject } from '../src/client/index.ts' +import { apply as applyNode } from '../src/index.ts' +import * as TaskInvariant from '../src/invariant.ts' +import { en, NS, zh } from '../src/client/locales.ts' + +/** Slot ledger reader: entry ids currently registered in the header list. */ +function headerEntryIds(ctx: Context): (string | undefined)[] { + return ctx.slots + .entries('conversation.session.header.actions') + .map(entry => entry.options.id) +} + +/** Boot the browser half over a real slot tree that declares the header list. */ +async function bench(): Promise<{ ctx: Context; fiber: ReturnType }> { + const ctx = new Context() + await ctx.plugin(SlotsService).await() + ctx.slots.register({ + name: 'root', + children: { + 'conversation.session.header.actions': { kind: 'list', scope: 'session' }, + }, + } as never, () => null) + ctx.provide('sessions', {}) + // The locale plugin binds a settings scope, which reads the connection handle. + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + await ctx.plugin({ inject: localeInject, apply: applyLocale }).await() + const fiber = ctx.plugin({ inject: [...inject], apply }) + await fiber.await() + return { ctx, fiber } +} + +describe('ui-task browser half', () => { + it('declares the services it binds', () => { + expect(inject).toEqual(['sessions', 'slots', 'locale']) + }) + + it('registers the header action, and fiber teardown removes it (HMR safety)', async () => { + const { ctx, fiber } = await bench() + expect(headerEntryIds(ctx)).toContain('task-list') + await fiber.dispose() + expect(headerEntryIds(ctx)).not.toContain('task-list') + }) + + it('registers both dictionaries under its own namespace and releases them with the fiber', async () => { + const { ctx, fiber } = await bench() + const translate = ctx.locale.bind(NS) + expect(translate('list.aria')).toBe(zh['list.aria']) + ctx.locale.setLocale('en') + expect(translate('list.aria')).toBe(en['list.aria']) + + // Withdrawn dictionaries leave the key unresolved rather than translated. + await fiber.dispose() + expect(translate('list.aria')).not.toBe(en['list.aria']) + }) + + it('keeps the English dictionary key-identical to the Chinese source of truth', () => { + expect(Object.keys(en).sort()).toEqual(Object.keys(zh).sort()) + }) +}) + +describe('ui-task node half', () => { + it('contributes no host behavior', () => { + // The node half exists only so the plugin appears in the Loader tree. + expect(applyNode).not.toThrow() + }) +}) + +describe('ui-task invariant companion', () => { + it('reserves package ownership under its declared companion name', async () => { + const ctx = new Context() + await ctx.plugin(InvariantService, { enabled: true }) + const fiber = ctx.plugin(TaskInvariant) + await fiber.await() + expect(TaskInvariant.name).toBe('client-ui-task-invariant') + expect(TaskInvariant.inject).toEqual(['invariants']) + // Emitting an unrelated event proves the companion installed no audit. + expect(() => { (ctx.emit as (event: string) => void)('slots/changed') }).not.toThrow() + await fiber.dispose() + }) +}) diff --git a/packages/client/ui-task/tests/task-list-action.spec.tsx b/packages/client/ui-task/tests/task-list-action.spec.tsx new file mode 100644 index 0000000000..0d03af3ec0 --- /dev/null +++ b/packages/client/ui-task/tests/task-list-action.spec.tsx @@ -0,0 +1,239 @@ +// @vitest-environment jsdom +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' +import { act, cleanup, fireEvent, render, screen, within } from '@testing-library/react' +import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' +import type { SessionId, SessionListState, TaskView } from '@deepseek-ai/dsh-client-runtime/client' +import { TaskListAction, type TaskListActionProps } from '../src/client/TaskListAction.tsx' +import { zh } from '../src/client/locales.ts' + +// Live rows render `now - startedAt`, so every assertion needs a pinned clock. +beforeEach(() => { + vi.useFakeTimers() + vi.setSystemTime(START) +}) + +afterEach(() => { + cleanup() + vi.useRealTimers() + vi.restoreAllMocks() +}) + +const SESSION = 'session' as SessionId +const START = 1_700_000_000_000 +const t: TaskListActionProps['t'] = makeTranslate(zh) + +function task(over: Partial = {}): TaskView { + return { + id: 'bash-1' as TaskView['id'], + kind: 'bash', + label: 'pnpm run build', + status: 'running', + startedAt: START, + ...over, + } +} + +function props(tasks: readonly TaskView[] | undefined): TaskListActionProps { + const state = { + ids: [SESSION], + byId: {}, + current: SESSION, + phase: 'ready', + subagentsByParent: {}, + tasksBySession: tasks === undefined ? {} : { [SESSION]: tasks }, + currentAddress: undefined, + } satisfies SessionListState + function useSessions(select: (snapshot: SessionListState) => T): T { + return select(state) + } + return { sessionId: SESSION, useSessions, t } as unknown as TaskListActionProps +} + +/** + * Rows in render order as `[kind, label, status, duration]`. Adjacent spans + * carry no whitespace between them, so the cells are read one element at a + * time rather than split out of a flattened string. + */ +function rowCells(): string[][] { + return within(screen.getByRole('list', { name: zh['list.aria'] })) + .getAllByRole('listitem') + .map(row => [...row.children] + .map(cell => cell.textContent ?? '') + .filter(text => text !== '')) +} + +describe('TaskListAction visibility', () => { + it('renders nothing while the session has no tasks', () => { + const { container } = render() + expect(container.innerHTML).toBe('') + }) + + it('counts only live tasks, and falls back to the total when none are live', () => { + const { rerender } = render() + expect(screen.getByRole('button', { name: '2 个后台任务运行中' })).toBeDefined() + + rerender() + expect(screen.getByRole('button', { name: '1 个后台任务' })).toBeDefined() + }) + + it('closes and unmounts when the last task disappears while the list is open', () => { + const { container, rerender } = render() + fireEvent.click(screen.getByRole('button')) + expect(screen.getByRole('list', { name: zh['list.aria'] })).toBeDefined() + + rerender() + expect(container.innerHTML).toBe('') + }) +}) + +describe('TaskListAction rows', () => { + it('orders live tasks by start, then settled tasks newest-first', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells()).toEqual([ + ['bash', 'earlier live', '运行中', '0秒'], + ['bash', 'later live', '运行中', '0秒'], + ['bash', 'new done', '已失败', '9秒'], + ['bash', 'old done', '已完成', '1秒'], + ]) + }) + + it('breaks a settled tie on start order so map iteration never decides it', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells().map(cells => cells[1])).toEqual(['first', 'second']) + }) + + it('prefers the producer detail over the generic status word', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells()[0]).toContain('signal: SIGTERM') + }) + + it('renders every status word, including the stopping transition', () => { + render() + fireEvent.click(screen.getByRole('button')) + const words = rowCells().map(cells => cells[2]) + expect(new Set(words)).toEqual(new Set(['运行中', '正在停止', '已完成', '已取消', '已失败'])) + }) +}) + +describe('TaskListAction duration', () => { + it('advances a live row once per second and freezes a settled one', () => { + vi.setSystemTime(START + 1_000) + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells()[0]).toContain('1秒') + expect(rowCells()[1]).toContain('4秒') + + act(() => { vi.advanceTimersByTime(2_000) }) + expect(rowCells()[0]).toContain('3秒') + expect(rowCells()[1]).toContain('4秒') + }) + + it('widens to minutes and then hours, and never shows a negative figure', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells().map(cells => cells[3])).toEqual(['2小时3分', '2分5秒', '0秒']) + }) + + it('runs no clock while the list is closed', () => { + const interval = vi.spyOn(globalThis, 'setInterval') + render() + expect(interval).not.toHaveBeenCalled() + fireEvent.click(screen.getByRole('button')) + expect(interval).toHaveBeenCalledTimes(1) + }) + + it('runs no clock for an open list holding only settled tasks', () => { + const interval = vi.spyOn(globalThis, 'setInterval') + render() + fireEvent.click(screen.getByRole('button')) + expect(interval).not.toHaveBeenCalled() + }) +}) + +describe('TaskListAction dismissal', () => { + it('closes on Escape and returns focus to the trigger', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.click(trigger) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + expect(document.activeElement).toBe(trigger) + }) + + it('ignores other keys and a closed-list Escape', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.keyDown(trigger, { key: 'Escape' }) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + + fireEvent.click(trigger) + fireEvent.keyDown(trigger, { key: 'ArrowDown' }) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + }) + + it('closes on an outside pointer press but not on one inside', () => { + render() + const trigger = screen.getByRole('button') + fireEvent.click(trigger) + + fireEvent.pointerDown(screen.getByRole('list', { name: zh['list.aria'] })) + expect(trigger.getAttribute('aria-expanded')).toBe('true') + + fireEvent.pointerDown(document.body) + expect(trigger.getAttribute('aria-expanded')).toBe('false') + }) +}) + +describe('TaskListAction wire tolerance', () => { + it('treats a settled task with no finishedAt as zero-duration and sorts it by start', () => { + // `finishedAt` is optional on the wire; the Host always sets it, so this + // covers a producer or carrier that ever stops doing so. + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells().map(cells => [cells[1], cells[3]])).toEqual([ + ['finished', '3秒'], + ['no finish', '0秒'], + ]) + }) + + it('falls back to start order when neither settled task carries a finish time', () => { + render() + fireEvent.click(screen.getByRole('button')) + expect(rowCells().map(cells => cells[1])).toEqual(['later', 'earlier']) + }) +}) diff --git a/packages/client/ui-task/tsconfig.json b/packages/client/ui-task/tsconfig.json new file mode 100644 index 0000000000..76ff7ad167 --- /dev/null +++ b/packages/client/ui-task/tsconfig.json @@ -0,0 +1,33 @@ +{ + "extends": "../../../tsconfig.base.client.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cordis" + }, + { + "path": "../locale" + }, + { + "path": "../runtime" + }, + { + "path": "../ui-conversation" + }, + { + "path": "../ui-primitives" + }, + { + "path": "../ui-slots" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/client/ui-task/tsdown.config.ts b/packages/client/ui-task/tsdown.config.ts new file mode 100644 index 0000000000..2be71f65dc --- /dev/null +++ b/packages/client/ui-task/tsdown.config.ts @@ -0,0 +1,3 @@ +import { clientBundle } from '../tsdown.client.ts' + +export default clientBundle('@deepseek-ai/dsh-client-ui-task', ['lib/types/index.js', 'lib/types/invariant.js']) diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index dbc65c11e9..99538ed6ec 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-theme", "description": "Theme plugin: ThemeService (light/dark/system preference, prefers-color-scheme resolution, theme/change snapshots; no DOM), --dsw-* token base stylesheets; registers the Appearance settings row", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-theme" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -36,13 +43,13 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-client-connection": "^0.0.1", - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-connection": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -53,7 +60,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ @@ -70,6 +77,6 @@ "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", "clsx": "^2.0.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/client/ui-theme/src/client/index.ts b/packages/client/ui-theme/src/client/index.ts index 8ef7f3fee7..aaa37ecca8 100644 --- a/packages/client/ui-theme/src/client/index.ts +++ b/packages/client/ui-theme/src/client/index.ts @@ -7,7 +7,7 @@ * document. The plugin also registers the Appearance preference row into the * settings General section — the theme feature owns its own settings surface. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { BoundActions } from '@deepseek-ai/dsh-client-ui-slots' import { bindSettingsScope, type ClientContext, type SettingsScope, @@ -66,7 +66,7 @@ export interface ThemeSnapshot { revision: number } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { theme: ThemeService } diff --git a/packages/client/ui-theme/src/index.ts b/packages/client/ui-theme/src/index.ts index 576028d37d..8b8e0c6d30 100644 --- a/packages/client/ui-theme/src/index.ts +++ b/packages/client/ui-theme/src/index.ts @@ -1,6 +1,6 @@ /** Host registration for the browser theme preference. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { settingsNamespace } from '@deepseek-ai/dsh-settings' import { THEME_SETTINGS_NAMESPACE, ThemeSettingsSchema } from './theme-settings.ts' diff --git a/packages/client/ui-theme/src/invariant.ts b/packages/client/ui-theme/src/invariant.ts index 51667dc5a9..6463b72f5f 100644 --- a/packages/client/ui-theme/src/invariant.ts +++ b/packages/client/ui-theme/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-theme' diff --git a/packages/client/ui-theme/src/theme-settings.ts b/packages/client/ui-theme/src/theme-settings.ts index d7fc966031..c2c268e21f 100644 --- a/packages/client/ui-theme/src/theme-settings.ts +++ b/packages/client/ui-theme/src/theme-settings.ts @@ -1,6 +1,6 @@ /** Theme preferences stored in the Host user-settings document. */ -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' /** Built-in preferences accepted at the registry and settings boundaries. */ export const THEME_PREFERENCES = ['light', 'dark', 'system'] as const diff --git a/packages/client/ui-theme/tests/appearance-row.spec.tsx b/packages/client/ui-theme/tests/appearance-row.spec.tsx index 7b1e11a69b..a6aa481879 100644 --- a/packages/client/ui-theme/tests/appearance-row.spec.tsx +++ b/packages/client/ui-theme/tests/appearance-row.spec.tsx @@ -22,7 +22,7 @@ const COPY: Record = { /** Empty global standard-kit hooks (the row reads neither). */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } function emptyWorkspaces() { diff --git a/packages/client/ui-theme/tests/apply.spec.ts b/packages/client/ui-theme/tests/apply.spec.ts index 25c2ac14df..006d64d621 100644 --- a/packages/client/ui-theme/tests/apply.spec.ts +++ b/packages/client/ui-theme/tests/apply.spec.ts @@ -1,7 +1,7 @@ /** ui-theme apply wiring: service provision, settings dictionaries riding the * locale service, declaration-aware Appearance row registration, snapshot * projection into the row store, and HMR collapse recovery. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-theme/tests/host.spec.ts b/packages/client/ui-theme/tests/host.spec.ts index 6cbbd91c27..0e99445892 100644 --- a/packages/client/ui-theme/tests/host.spec.ts +++ b/packages/client/ui-theme/tests/host.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { Settings, settingsNamespace, type SettingsNamespace } from '@deepseek-ai/dsh-settings' import { diff --git a/packages/client/ui-theme/tests/invariant.spec.ts b/packages/client/ui-theme/tests/invariant.spec.ts index c5eedc9dd7..1e7e4a75ba 100644 --- a/packages/client/ui-theme/tests/invariant.spec.ts +++ b/packages/client/ui-theme/tests/invariant.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-theme' import { apply as clientApply, inject, ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' import * as ThemeInvariant from '@deepseek-ai/dsh-client-ui-theme/invariant' diff --git a/packages/client/ui-theme/tests/theme.spec.ts b/packages/client/ui-theme/tests/theme.spec.ts index b7fc3bd17a..774b7dd10a 100644 --- a/packages/client/ui-theme/tests/theme.spec.ts +++ b/packages/client/ui-theme/tests/theme.spec.ts @@ -1,6 +1,6 @@ // @vitest-environment jsdom import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { stubSettingsScope, type StubSettingsScope } from '@deepseek-ai/dsh-client-test-runtime' import type { ThemeSettings, ThemeSnapshot } from '@deepseek-ai/dsh-client-ui-theme/client' import { ThemeService } from '@deepseek-ai/dsh-client-ui-theme/client' diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index c40b09abdb..6644d1d506 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-tool" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -41,13 +48,13 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-conversation": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -62,7 +69,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx index 880938567f..a8a7a6497b 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/ask-question-row.tsx @@ -7,7 +7,7 @@ // render in the composer takeover. import { IconQuestionOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { toolRowModel } from '../models/tool-call-model.ts' diff --git a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx index 71a8503e69..dc9a02a901 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/bash-sample.tsx @@ -14,7 +14,7 @@ // collapsed summary is the failure's first line in the error color. import { useState, type KeyboardEvent } from 'react' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import clsx from 'clsx' import { IconApiOutline14, IconChevronDownOutline14, IconInspectOutline12, StateDot, TerminalBlock, diff --git a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx index 8dd7a37306..9616dab7cd 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/file-mutation-row.tsx @@ -8,7 +8,7 @@ // `result.isError`) keeps the model-facing error text on ToolRow's Output // section, its first line in the collapsed summary. -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { IconEditOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx index c404f535e5..c066b6667a 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/read-row.tsx @@ -7,7 +7,7 @@ // yet) and a non-read result render the summary row alone: the read intent is // result-side only, so there is no running-state read card to draw. -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { IconBrowseOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx index 10fea1b8c8..47b17b6fa8 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/search-row.tsx @@ -11,7 +11,7 @@ // nested run_code sub-dispatch, a legacy generic result) surfaces its // model-facing text through ToolRow's Output section instead. -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' diff --git a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx index 111189aede..143a43ee29 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/todo-row.tsx @@ -8,7 +8,7 @@ // above the composer, so the row stays one line until expanded. import { IconChecklistOutline14 } from '@deepseek-ai/dsh-client-ui-primitives' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' import { toolRowModel } from '../models/tool-call-model.ts' diff --git a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx index 80489c478f..c0e546f071 100644 --- a/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx +++ b/packages/client/ui-tool/src/client/tool/toolviews/web-row.tsx @@ -9,7 +9,7 @@ // no web card (the tools keep a generic pending view), so a running row is the // summary line alone. -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { IconBrowseOutline16, IconSearchOutline16 } from '@deepseek-ai/dsh-client-ui-primitives' import type { PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { ToolCallViewProps } from '../../contract/slots.ts' diff --git a/packages/client/ui-tool/src/invariant.ts b/packages/client/ui-tool/src/invariant.ts index bfee949195..e00671b0d7 100644 --- a/packages/client/ui-tool/src/invariant.ts +++ b/packages/client/ui-tool/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-tool' diff --git a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index df5dcf6718..5939655118 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -8,11 +8,12 @@ // and a file sub-row click opens the host path. Running parents // (runningCalls) nest their so-far dispatches the same way. -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { - ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, SlotsService, + ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, + EMPTY_CONVERSATION_VIEWS, SlotsService, } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, @@ -78,7 +79,8 @@ function snapshotWith( const nestedNodes = nodes.map(node => ({ ...node, subCalls })) const nestedRunningCalls = runningCalls.map(call => ({ ...call, subCalls })) return { - sessionId: SID, chat: toolChatSnapshot(nestedNodes, nestedRunningCalls), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, + chat: toolChatSnapshot(nestedNodes, nestedRunningCalls), nodes: nestedNodes, turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: nestedRunningCalls, pending: [], queue: [], running: runningCalls.length > 0, composerPhase: 'active', removed: false, @@ -110,7 +112,7 @@ async function bench(snapshot: ConversationSnapshot) { ids: [SID], byId: { [SID]: { id: SID, title: 'S', displayTitle: 'S', running: false, blank: false, updatedAt: 1 } }, current: SID, - phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const scoped = { send: vi.fn(async () => {}), cancel: vi.fn(async () => {}) } const layout = { openDetails: vi.fn(), closeDetails: vi.fn() } diff --git a/packages/client/ui-tool/tests/coverage-tails.spec.tsx b/packages/client/ui-tool/tests/coverage-tails.spec.tsx index ab1a0a9632..8010fb8763 100644 --- a/packages/client/ui-tool/tests/coverage-tails.spec.tsx +++ b/packages/client/ui-tool/tests/coverage-tails.spec.tsx @@ -30,7 +30,7 @@ function listStore() { }, current: undefined, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) } diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 600949f5e8..1726c81136 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -7,7 +7,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -157,7 +159,7 @@ describe('FileMutationRow diff card', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) @@ -309,13 +311,13 @@ describe('DetailsPanel diff Output section', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ @@ -351,7 +353,8 @@ describe('DetailsPanel diff Output section', () => { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, + chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index 012ae754b2..ae719173d5 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -8,9 +8,11 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import { makeTranslate } from '@deepseek-ai/dsh-client-test-runtime' import { zh as commonZh } from '@deepseek-ai/dsh-client-locale/src/locales/zh.ts' import type { @@ -171,7 +173,7 @@ describe('ReadRow keyed toolview', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd: '/w/app' } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) @@ -255,13 +257,13 @@ describe('DetailsPanel Output section (read)', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ @@ -297,7 +299,8 @@ describe('DetailsPanel Output section (read)', () => { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, + chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.spec.tsx index b41b665ea8..3ff068b3c9 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.spec.tsx @@ -9,7 +9,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -378,7 +380,7 @@ describe('DetailsPanel Output section (search)', () => { if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, @@ -413,7 +415,8 @@ describe('DetailsPanel Output section (search)', () => { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, + chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index a868ffa744..dd39b8bc88 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -7,7 +7,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -346,7 +348,7 @@ describe('BashRow terminal card', () => { byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0 } }, current: undefined, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) @@ -446,13 +448,13 @@ describe('DetailsPanel Output section', () => { const chat = createChatStore().create() if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore(cwd === undefined - ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined } + ? { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined } : { ids: [SID], byId: { [SID]: { id: SID, displayTitle: 'r', running: false, blank: false, updatedAt: 0, cwd } }, current: SID, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ @@ -482,7 +484,8 @@ describe('DetailsPanel Output section', () => { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, + chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, @@ -646,7 +649,7 @@ describe('DetailsPanel Output section', () => { useSessions={bindSnapshotSelector(createSnapshotStore( { ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }))} useWorkspaces={bindSnapshotSelector(createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx index 927b760b82..04535a0954 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -10,7 +10,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' +import { + createSnapshotStore, EMPTY_CONVERSATION_VIEWS, +} from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RunningToolCall, SessionId, SessionListState, ToolResultNode, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' @@ -208,7 +210,7 @@ describe('DetailsPanel web Output section', () => { if (selection !== null) chat.actions.select(selection) const sessions = createSnapshotStore({ ids: [], byId: {}, current: undefined, phase: 'ready', - subagentsByParent: {}, currentAddress: undefined, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspaces = createSnapshotStore({ items: [], archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, @@ -243,7 +245,8 @@ describe('DetailsPanel web Output section', () => { const nodes = over.nodes ?? [] const runningCalls = over.runningCalls ?? [] return { - sessionId: SID, chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), + sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, + chat: over.chat ?? toolChatSnapshot(nodes, runningCalls), nodes: [], turnTimings: new Map(), turnEnds: new Map(), partial: null, runningCalls: [], pending: [], queue: [], running: false, composerPhase: 'active', removed: false, openState: 'open', openError: null, hasMore: false, loadingOlder: false, @@ -294,7 +297,7 @@ describe('web toolview registration', () => { return () => {} }, }, - } as unknown as import('cordis').Context + } as unknown as import('@deepseek-ai/cordis').Context webToolview.apply(ctx) expect(registered.map(r => r.key)).toEqual(['web_search', 'web_fetch']) // Both keys claim the conversation locale seat ToolRow's body copy needs. diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 7fecd92d74..baba46ae81 100644 --- a/packages/client/ui-trajectory/README.i18n.yaml +++ b/packages/client/ui-trajectory/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/client/ui-trajectory/README.md -README.md: 5b8c0cd111c272007212fea0d2435c5fab2360ab -README.zh.md: 9aaa02ccd9d50b0f0b23e9a53ea9b1048d0e513f +README.md: e82b2cc9d4a65c3095aeee7002fb6c43a43b695d +README.zh.md: a1ba62393c2aae3f6baa7c481dd80f04dbbb477d diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 5b8c0cd111..e82b2cc9d4 100644 --- a/packages/client/ui-trajectory/README.md +++ b/packages/client/ui-trajectory/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full branch. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. Completed replies retain only the first visible token and usage chunks in the inspection projection, while unfinished and interrupted replies retain every chunk; the independent source keeps the raw history unchanged. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. The runtime's independent history source supplies raw context lineage and projects cancellation-frozen Assistant and Tool records, so Trajectory neither reads nor changes the Chat conversation snapshot. The package remains a pure-consumer plugin (registers one view tab into the conversation's `'conversation.view'` slot ring, provides no service, declares no Context merge). +Trajectory renders a turn-aware event ledger with selectable User, Assistant, Tool, and nested Subtool records. Thick rules mark Turn boundaries, compact inline markers identify Steps, and the main ledger keeps only index, event, and content; selection opens a local inspector for token usage, duration, Input, Output, and Timing. Scrollable Summary regions keep their scrollbar thumbs transparent until the region is hovered or contains keyboard focus, without changing the reserved scroll geometry. A standalone compaction request appears chronologically in its own `Between turns` section, while a numbered compaction remains inside its owning turn. Long ledgers open at the current tail, load one older page when the user reaches the loaded range's top, and mount only the visible row window plus a small overscan; request-only separators share the next measurable virtual item, while semantic row keys and ARIA indexes survive prepends. Selection, timeline navigation, folding, search, and Request totals cover the currently loaded window. The ledger covers records with an explicit loading row until the initial tail is positioned and while an older page is pending. A fixed Overview above the ledger projects real record start/duration timing from left to right; when earlier records remain unloaded and the viewport includes the loaded domain's start, a neutral ellipsis control identifies the omitted prefix and loads one earlier page without assigning unknown history fabricated duration. Assistant spans divide recorded TTFT from decoding, and a 500 ms hover reveals exact clock and duration details. Dragging an interval focuses the ledger on every record active at any point in that inclusive range, while clearing the selection restores the full loaded ledger. Wheel gestures zoom the time domain. A right-button click clears the selected interval, while a right-button drag pans an already zoomed viewport without changing it. The initial view and streaming updates stay at the tail; scrolling upward suspends following so new records do not interrupt inspection of earlier rows. Content-only stream frames preserve virtual row keys and heights, reuse measurements, and do not issue repeated tail-scroll writes. The toolbar's Export button downloads the session log — the root plus every subagent descendant — as a ZIP streamed by the host (`GET /api/session.export`): every file is the session's stored artifact text verbatim (`session.jsonl` at the root, `subagents//session.jsonl` for descendants; no manifest, byte-identical to the backend's durable artifact), and every image any included log references sits under `media/.`. Fixture mode (no host) answers 404 for the export. Completed replies retain assembled blocks, timing, and usage in Trajectory target State, while the shared Session window keeps the raw Events. Trajectory asks the conversation shell to float the composer over the full-height ledger, while its responsive vertical scrollers reserve the composer's live height so final rows remain reachable. Trajectory-owned Definitions assemble business records, including cancellation-frozen Assistant and Tool records, from the shared Session window, so Trajectory neither reads nor changes the Chat conversation snapshot. The package provides no service and declares no Context merge; it registers target-specific Event Definitions, a Trajectory view builder, and one tab in the conversation's `'conversation.view'` slot ring. Contract: api-contracts v3 §8. ## Model Experience diff --git a/packages/client/ui-trajectory/README.zh.md b/packages/client/ui-trajectory/README.zh.md index 9aaa02ccd9..a1ba62393c 100644 --- a/packages/client/ui-trajectory/README.zh.md +++ b/packages/client/ui-trajectory/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复在检查投影中仅保留首个可见 token 和用量分片,未完成及中断的回复则保留所有分片;独立数据源中的原始历史保持不变。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。运行时的独立历史数据源提供原始上下文谱系,并投影因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包保持为纯消费方插件(向会话的 `'conversation.view'` slot 环注册一个视图标签页,不提供服务,也不声明 Context 合并)。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。工具栏的 “Export” 按钮会将会话日志——根会话及其全部子代理——下载为宿主流式返回的 ZIP(`GET /api/session.export`):每个文件都是会话存储工件的逐字原文(根为 `session.jsonl`,子代理为 `subagents//session.jsonl`;无清单,与后端持久化工件逐字节一致),每个被包含日志引用的图片则放在 `media/.` 下。fixture 模式(无宿主)对导出应答 404。已完成的回复会在 Trajectory target State 中保留组装后的 blocks、计时与用量,共享 Session 窗口则保留原始 Event。Trajectory 要求会话壳将 composer 作为浮层置于全高记录表上方;其响应式纵向滚动容器会预留 composer 的实时高度,确保仍可滚动到最后几行。Trajectory 自有的 Definition 从共享 Session 窗口组装业务记录,其中包括因取消而冻结的助手和工具记录,因此 Trajectory 既不读取也不改变 Chat 会话快照。该包不提供 service,也不声明 Context 合并;它会注册 target 专属 Event Definition、Trajectory view builder,以及会话 `'conversation.view'` slot 环中的一个视图标签页。约定:api-contracts v3 §8。 ## 模型体验 diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 9ed4b550c1..49b100620b 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-trajectory", "description": "Trajectory event ledger with an interactive timing overview: pure-consumer plugin registering into the conversation ViewMap (no service)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-trajectory" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,6 +32,7 @@ "dsh": { "client": { "inject": [ + "@deepseek-ai/dsh-client-locale", "@deepseek-ai/dsh-client-runtime", "@deepseek-ai/dsh-client-ui-conversation" ], @@ -41,22 +49,30 @@ "diff": "^9.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, "devDependencies": { + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-client-locale": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", "@deepseek-ai/dsh-client-ui-conversation": "workspace:^", "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 9cef2dccfb..5d2d4d8e31 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -24,7 +24,8 @@ import { groupTrajectoryVirtualRows, trajectoryVirtualRecordKey, } from './trajectory-virtual-rows.ts' import type { TrajectoryVirtualRow } from './trajectory-virtual-rows.ts' -import { trajectoryPreviewText, type TrajectoryTurnModel } from './layout.ts' +import type { TrajectoryTurnModel } from './layout.ts' +import { trajectoryPreviewText } from './trajectory-preview.ts' import css from './TrajectoryTable.module.css' const BOTTOM_FOLLOW_THRESHOLD_PX = 2 @@ -155,8 +156,8 @@ type DetailTab = | 'tools' | 'overview' | 'rendered' + | 'raw' | 'source' - | 'origin' | 'input' | 'output' | 'schema' @@ -491,6 +492,21 @@ function requestKey(turn: number | null, group: string): string { return `${turn}\u0000${group}` } +function indexRequestBoundaries(records: readonly TableRecord[]): ReadonlyMap { + const boundaries = new Map() + for (const record of records) { + const key = requestKey(record.turn, record.group) + if (boundaries.has(key)) continue + if (requestStep(record.group) === undefined) { + if (record.groupStart) boundaries.set(key, record.cell.index) + continue + } + if (record.cell.kind === 'user' || record.cell.kind === 'context') continue + boundaries.set(key, record.cell.index) + } + return boundaries +} + function sectionLabel(turn: number | null): string { return turn === null ? 'Between turns' : `Turn ${turn}` } @@ -498,16 +514,18 @@ function sectionLabel(turn: number | null): string { function indexRequestNumbers( records: readonly TableRecord[], sessionNumbers: readonly TrajectoryRequestNumber[] | undefined, + boundaries: ReadonlyMap, ): ReadonlyMap { const numbers = new Map() for (const request of sessionNumbers ?? []) { numbers.set(requestKey(request.turn, request.group), request.number) } let next = Math.max(0, ...numbers.values()) + 1 - const boundaries = records - .filter(record => record.groupStart && requestStep(record.group) !== undefined) + const boundaryRecords = records + .filter(record => boundaries.get(requestKey(record.turn, record.group)) === record.cell.index + && requestStep(record.group) !== undefined) .sort((left, right) => left.cell.index - right.cell.index) - for (const record of boundaries) { + for (const record of boundaryRecords) { const key = requestKey(record.turn, record.group) if (!numbers.has(key)) numbers.set(key, next++) } @@ -782,7 +800,7 @@ function RequestOptions({ ) } -function messageOriginLabel(source: unknown): string { +function messageSourceLabel(source: unknown): string { if (typeof source !== 'object' || source === null || Array.isArray(source)) { return 'Unknown' } @@ -805,16 +823,16 @@ function messageOriginLabel(source: unknown): string { return `${kind[0]?.toUpperCase() ?? ''}${kind.slice(1)}` } -function MessageOrigin({ record }: { record: TableRecord }) { +function MessageSource({ record }: { record: TableRecord }) { const source = record.cell.messageSource - if (source === undefined) return

Origin not recorded

+ if (source === undefined) return

Source not recorded

const data = typeof source === 'object' && source !== null ? source : { value: source } return ( ) @@ -879,17 +897,17 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { if (record.cell.kind === 'compacted') { return [ { id: 'overview', label: 'Summary' }, - { id: 'source', label: 'Raw Output' }, + { id: 'raw', label: 'Raw Output' }, ] } if (isMarkdownRecord(record)) { return [ { id: 'overview', label: 'Summary' }, { id: 'rendered', label: 'Preview' }, - { id: 'source', label: 'Source' }, + { id: 'raw', label: 'Raw' }, ...(record.cell.messageSource === undefined ? [] - : [{ id: 'origin', label: 'Origin' } as const]), + : [{ id: 'source', label: 'Source' } as const]), ] } return [ @@ -903,6 +921,11 @@ function detailTabs(record: TableRecord): readonly DetailTabItem[] { function recordDisplayText(cell: TrajectoryCellProps): string { if (isToolCallOnly(cell)) return '' + if (cell.previewMarkdown !== undefined) { + const preview = trajectoryPreviewText(cell.previewMarkdown) + if (cell.text === '') return preview + return preview === '' ? cell.text : `${cell.text} · ${preview}` + } if (cell.text !== '') return cell.text const markdown = cell.kind === 'user' || cell.kind === 'context' ? cell.inputDetail @@ -912,6 +935,12 @@ function recordDisplayText(cell: TrajectoryCellProps): string { return markdown === undefined ? '' : trajectoryPreviewText(markdown) } +function recordResultText(cell: TrajectoryCellProps): string | undefined { + return cell.resultPreviewMarkdown === undefined + ? cell.result + : trajectoryPreviewText(cell.resultPreviewMarkdown) +} + function toolCallTextParts( kind: TrajectoryCellKind, text: string, @@ -932,6 +961,71 @@ function isToolCallOnly(cell: TrajectoryCellProps): boolean { && cell.text === 'Tool call only' } +interface RecordPresentationValue { + displayText: string + listDisplayText: string + resultText: string | undefined + toolCallOnly: boolean + toolCallText: ToolCallTextParts | undefined +} + +function RecordPresentation({ + cell, + children, +}: { + cell: TrajectoryCellProps + children: (value: RecordPresentationValue) => ReactNode +}) { + const displayText = useMemo( + () => recordDisplayText(cell), + [ + cell.kind, cell.text, cell.previewMarkdown, + cell.inputDetail, cell.outputDetail, cell.thinkingDetail, + ], + ) + const resultText = useMemo( + () => recordResultText(cell), + [cell.result, cell.resultPreviewMarkdown], + ) + const toolCallOnly = isToolCallOnly(cell) + const toolCallText = toolCallTextParts(cell.kind, displayText) + const listDisplayText = toolCallOnly + ? '(tool call only)' + : toolCallText === undefined + ? displayText + : [toolCallText.name, toolCallText.args].filter(Boolean).join(' ') + return children({ + displayText, + listDisplayText, + resultText, + toolCallOnly, + toolCallText, + }) +} + +function RecordListText({ + displayText, + toolCallOnly, + toolCallText, +}: Pick) { + if (toolCallOnly) { + return (tool call only) + } + if (toolCallText === undefined) return displayText || '—' + return ( + <> + + {toolCallText.name || '—'} + + {toolCallText.args !== undefined && ( + + {toolCallText.args} + + )} + + ) +} + function MarkdownFragment({ text, rendered, @@ -1654,9 +1748,10 @@ export function TrajectoryTable({ useEffect(() => { onSelectedIndexChange?.(selectedIndex) }, [onSelectedIndexChange, selectedIndex]) + const requestBoundaries = useMemo(() => indexRequestBoundaries(allRecords), [allRecords]) const requestNumbers = useMemo( - () => indexRequestNumbers(allRecords, sessionRequestNumbers), - [allRecords, sessionRequestNumbers], + () => indexRequestNumbers(allRecords, sessionRequestNumbers, requestBoundaries), + [allRecords, requestBoundaries, sessionRequestNumbers], ) const records = useMemo(() => { if (searchMatchIndexes !== null) return filterRecords(allRecords, searchMatchIndexes) @@ -2131,263 +2226,251 @@ export function TrajectoryTable({ /> )} - {renderedRecords.map(({ record, position, terminalRequestBoundary }) => { - const displayText = recordDisplayText(record.cell) - const toolCallOnly = isToolCallOnly(record.cell) - const toolCallText = toolCallTextParts(record.cell.kind, displayText) - const listDisplayText = toolCallOnly - ? '(tool call only)' - : toolCallText === undefined - ? displayText - : [toolCallText.name, toolCallText.args].filter(Boolean).join(' ') - const isCollapsedSummary = record.collapsedSummary !== undefined - const isRequestOnly = record.cell.requestOnly === true - const isInitialSystem = record.cell.kind === 'system' + {renderedRecords.map(({ record, position, terminalRequestBoundary }) => ( + + {({ displayText, listDisplayText, resultText, toolCallOnly, toolCallText }) => { + const isCollapsedSummary = record.collapsedSummary !== undefined + const isRequestOnly = record.cell.requestOnly === true + const isInitialSystem = record.cell.kind === 'system' && record.cell.index === allRecords[0]?.cell.index - const request = record.groupStart + const key = requestKey(record.turn, record.group) + const request = requestBoundaries.get(key) === record.cell.index && !isCollapsedSummary && (record.turn === null || !collapsedTurns.has(record.turn)) - ? requestNumbers.get(requestKey(record.turn, record.group)) - : undefined - const requestInfo = request === undefined - ? undefined - : sessionRequestNumbers?.find(candidate => candidate.number === request) - const requestStatus = requestInfo?.status + ? requestNumbers.get(key) + : undefined + const requestInfo = request === undefined + ? undefined + : sessionRequestNumbers?.find(candidate => candidate.number === request) + const requestStatus = requestInfo?.status ?? (record.cell.isError === true ? 'error' : undefined) - const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0 - const requestBoundaryStyle: RequestBoundaryStyle = { - '--request-boundary-offset': `${requestRunIndex * 8}px`, - } - const requestLabel = request === undefined - ? undefined - : `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}` - const requestSelected = request !== undefined + const requestRunIndex = requestBoundaryRuns.get(record.cell.index) ?? 0 + const requestBoundaryStyle: RequestBoundaryStyle = { + '--request-boundary-offset': `${requestRunIndex * 8}px`, + } + const requestLabel = request === undefined + ? undefined + : `Request #${request}${requestInfo?.purpose === 'compaction' ? ' · Compaction' : ''}` + const requestSelected = request !== undefined && selectedRequest?.turn === record.turn && selectedRequest.group === record.group - const sectionActive = record.turn === null - ? activeSection === record.section - : activeTurn === record.turn - return ( - { - if (record.collapsedSummaryKind === 'turn' && record.turn !== null) { + const sectionActive = record.turn === null + ? activeSection === record.section + : activeTurn === record.turn + return ( + { + if (record.collapsedSummaryKind === 'turn' && record.turn !== null) { + onToggleTurn(record.turn) + } else onToggleAssistant(trajectoryRecordId(record.cell)) + } + : () => { selectRecord(record.cell.index) }} + onDoubleClick={(event) => { + if (isCollapsedSummary || isRequestOnly) return + if (record.turn !== null && collapsedTurns.has(record.turn)) { + event.preventDefault() onToggleTurn(record.turn) - } else onToggleAssistant(trajectoryRecordId(record.cell)) - } - : () => { selectRecord(record.cell.index) }} - onDoubleClick={(event) => { - if (isCollapsedSummary || isRequestOnly) return - if (record.turn !== null && collapsedTurns.has(record.turn)) { - event.preventDefault() - onToggleTurn(record.turn) - return - } - if ( - record.cell.kind === 'message' + return + } + if ( + record.cell.kind === 'message' && assistantToolCalls(allRecords, record.cell.index).length > 0 - ) { - event.preventDefault() - onToggleAssistant(trajectoryRecordId(record.cell)) - return - } - if (!record.turnStart) return - if (record.turn === null) return - if (allRecords.filter(candidate => - candidate.turn === record.turn + ) { + event.preventDefault() + onToggleAssistant(trajectoryRecordId(record.cell)) + return + } + if (!record.turnStart) return + if (record.turn === null) return + if (allRecords.filter(candidate => + candidate.turn === record.turn && candidate.cell.requestOnly !== true && candidate.cell.kind !== 'system').length <= 1) return - event.preventDefault() - onToggleTurn(record.turn) - }} - onKeyDown={(event) => { - if (isRequestOnly) return - if (event.key !== 'Enter' && event.key !== ' ') return - event.preventDefault() - if (isCollapsedSummary) { - if (record.collapsedSummaryKind === 'turn' && record.turn !== null) { + event.preventDefault() onToggleTurn(record.turn) - } else onToggleAssistant(trajectoryRecordId(record.cell)) - return - } - selectRecord(record.cell.index) - }} - > - - {request !== undefined && ( - +
@@ -111,8 +139,8 @@ export function TrajectoryToolbar({ { onSearchQueryChange(event.currentTarget.value) }} /> diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 6d77f907f1..99eb823ae6 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -2,14 +2,11 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import type { ConvViewProps } from '@deepseek-ai/dsh-client-ui-conversation/client' -import type { InjectFace } from '@deepseek-ai/dsh-client-ui-slots' +import type { InjectFace, PropsLocale } from '@deepseek-ai/dsh-client-ui-slots' import type { - AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot, - SessionHistoryFace, SnapshotStore, + AssistantBlock, AssistantMessageNode, ConversationSnapshot, + SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' -import { - deriveTrajectoryContextBranches, trajectoryBranchContainsRequest, -} from './context-branches.ts' import { TrajectoryTable, type TrajectoryRequestNumber, @@ -27,10 +24,13 @@ import { type TrajectoryTimeRange, } from './timeline.ts' import { trajectoryRecordId } from './trajectory-record.ts' +import { TrajectorySearchIndex } from './trajectory-search-index.ts' +import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts' import css from './views.module.css' const EMPTY_TURN_IDS: ReadonlySet = new Set() const EMPTY_RECORD_IDS: ReadonlySet = new Set() +const SEARCH_INDEX_THROTTLE_MS = 3_000 function lastCellIndex(turns: readonly TrajectoryTurnModel[]): number { let last = 0 @@ -64,15 +64,15 @@ function partialStructureSignature(partial: ConversationSnapshot['partial']): st : block.kind).join('\u0000') } -/** Session-history paging needed by the event-complete trajectory view. */ +/** Session-bound controls not already supplied by the conversation view slot. */ export interface TrajectoryViewInjected { hooks: { - history: SessionHistoryFace duration: SnapshotStore } - loadHistoryTail: (signal: AbortSignal) => Promise - loadOlderHistory: (signal: AbortSignal) => Promise + loadOlder: () => Promise setActualDuration: (actualDuration: boolean) => void + /** Download the session log (including subagent logs) as a ZIP archive; rejects on failure. */ + exportLog: () => Promise } interface UsageLike { @@ -119,84 +119,23 @@ function addUsage( } } -function searchableJson(value: unknown): string { - if (value === undefined) return '' - try { - return JSON.stringify(value) - } catch { - return '' - } -} - -function searchMatches( - turns: ReturnType, - query: string, -): ReadonlySet | null { - const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean) - if (terms.length === 0) return null - const matches = new Set() - for (const turn of turns) { - for (const group of turn.groups) { - for (const cell of group.cells) { - if (cell.requestOnly === true) continue - const blocks = [ - ...(cell.sourceBlocks ?? []), - ...(cell.outputBlocks ?? []), - ] - const text = [ - turn.turn === null ? 'between turns' : `turn ${turn.turn}`, - group.title, - cell.kind, - cell.kind === 'message' ? 'assistant' : undefined, - cell.text, - cell.inputDetail, - cell.outputDetail, - cell.thinkingDetail, - cell.schemaDetail, - cell.result, - cell.callId, - ...blocks.flatMap(block => [ - block.type, - block.content, - block.callId, - block.toolName, - block.imageAlt, - ]), - searchableJson(cell.messageSource), - searchableJson(cell.promptDetail), - searchableJson(cell.previousPromptDetail), - ].filter((value): value is string => typeof value === 'string') - .join('\n') - .toLocaleLowerCase() - if (terms.every(term => text.includes(term))) matches.add(cell.index) - } - } - } - return matches -} - -function mergeSearchMatches( - finalized: ReadonlySet | null, - partial: ReadonlySet | null, -): ReadonlySet | null { - if (finalized === null || partial === null) return null - return new Set([...finalized, ...partial]) -} - export function TrajectoryView({ - useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, - inspect, onInspectDone, -}: ConvViewProps & InjectFace) { + useSession, useDuration, loadOlder, setActualDuration, exportLog, + inspect, onInspectDone, t, +}: ConvViewProps & InjectFace & PropsLocale<'trajectory'>) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) const [collapsedAssistants, setCollapsedAssistants] = useState>(EMPTY_RECORD_IDS) - const [timelineSelection, setTimelineSelection] = useState<{ - branchKey: string - range: TrajectoryTimeRange - } | null>(null) + const [timelineSelection, setTimelineSelection] = useState(null) const actualDuration = useDuration(value => value) const [actualTime, setActualTime] = useState(false) const [searchQuery, setSearchQuery] = useState('') + const [exporting, setExporting] = useState(false) + const [exportError, setExportError] = useState(null) + const [searchIndex] = useState(() => new TrajectorySearchIndex()) + const [searchIndexRevision, setSearchIndexRevision] = useState(0) + const searchIndexTimer = useRef | null>(null) + const searchIndexInitialized = useRef(false) const [selectedTimelineIndex, setSelectedTimelineIndex] = useState(null) const [timelineRecordSelection, setTimelineRecordSelection] = useState<{ readonly index: number @@ -204,60 +143,20 @@ export function TrajectoryView({ const [timelineRecordFocus, setTimelineRecordFocus] = useState<{ readonly index: number } | null>(null) - const inspection = useHistory(snapshot => snapshot.inspection) - const historyLoading = useHistory(snapshot => - snapshot.state === 'cold' || snapshot.state === 'loading') - const hasOlderHistory = useHistory(snapshot => snapshot.hasMore) - const historyBaseSeq = useHistory(snapshot => snapshot.baseSeq) + const inspection = useSession(snapshot => + snapshot.views.get('trajectory') ?? EMPTY_TRAJECTORY_SNAPSHOT) + const historyLoading = useSession(snapshot => + snapshot.openState === 'loading' || snapshot.loadingOlder) + const hasOlderHistory = useSession(snapshot => snapshot.hasMore) const nodes = inspection.eventNodes + const eventLocations = inspection.eventLocations + const historyBaseSeq = nodes[0]?.seq ?? 0 const partial = inspection.partial const runningCalls = inspection.runningCalls - const loadHistoryTailRef = useRef(loadHistoryTail) - loadHistoryTailRef.current = loadHistoryTail - const historyControllerRef = useRef(null) - useEffect(() => { - const controller = new AbortController() - historyControllerRef.current = controller - void loadHistoryTailRef.current(controller.signal) - return () => { controller.abort() } - }, []) const requests = inspection.requests const callSchemas = inspection.callSchemas - const historyContexts = inspection.contexts - const interruptedNodes = inspection.interruptedNodes - const contexts = useMemo( - () => historyContexts.length === 0 - ? [{ id: 0, nodes }] - : historyContexts, - [historyContexts, nodes], - ) - const branches = useMemo( - () => deriveTrajectoryContextBranches(contexts), - [contexts], - ) - const currentBranch = branches.at(-1) - if (currentBranch === undefined) throw new Error('trajectory branch projection must not be empty') - const selectedNodes = useMemo(() => { - const selected = new Map(currentBranch.nodes.map(node => [node.seq, node])) - for (const node of interruptedNodes) { - selected.set(node.seq, node) - } - return [...selected.values()].sort((left, right) => left.seq - right.seq) - }, [currentBranch.nodes, interruptedNodes]) - const selectedRequests = useMemo( - () => requests.filter(request => - trajectoryBranchContainsRequest(currentBranch, request), - ), - [currentBranch, requests], - ) const requestNumbers = useMemo(() => { const assistantsByStep = new Map() - for (const context of contexts) { - for (const node of context.nodes) { - if (node.kind !== 'assistant' || node.step <= 0) continue - assistantsByStep.set(`${node.turn}\u0000${node.step}`, node) - } - } for (const node of nodes) { if (node.kind !== 'assistant' || node.step <= 0) continue assistantsByStep.set(`${node.turn}\u0000${node.step}`, node) @@ -353,24 +252,25 @@ export function TrajectoryView({ return numbered }, [ - contexts, nodes, requests, + nodes, requests, ]) const partialTurn = partial?.turn ?? null const partialStep = partial?.step ?? null const finalized = useMemo(() => { const turns = deriveTrajectoryLayout({ - nodes: selectedNodes, + nodes, + eventLocations, partial: partialTurn === null || partialStep === null ? null : { turn: partialTurn, step: partialStep, blocks: [] }, runningCalls, - requests: selectedRequests, + requests, callSchemas, }) return { turns, lastIndex: lastCellIndex(turns) } }, [ - selectedNodes, partialTurn, partialStep, - runningCalls, selectedRequests, callSchemas, + nodes, eventLocations, partialTurn, partialStep, + runningCalls, requests, callSchemas, ]) const timelinePartialSignature = partialStructureSignature(partial) const timelinePartial = useMemo(() => partial === null @@ -388,31 +288,60 @@ export function TrajectoryView({ const timelineMode: TrajectoryTimelineMode = actualDuration ? actualTime ? 'actual' : 'duration' : actualTime ? 'time' : 'sequence' - const finalizedSearchMatches = useMemo( - () => searchMatches(finalized.turns, searchQuery), - [finalized, searchQuery], - ) const partialSearchTurns = useMemo( () => appendTrajectoryPartialLayout([], partial, finalized.lastIndex), [finalized.lastIndex, partial], ) + const searchLayouts = useMemo( + () => [finalized.turns, partialSearchTurns] as const, + [finalized, partialSearchTurns], + ) + const latestSearchLayouts = useRef(searchLayouts) + latestSearchLayouts.current = searchLayouts + useEffect(() => { + if (!searchIndexInitialized.current) { + searchIndexInitialized.current = true + if (searchIndex.update(searchLayouts)) { + setSearchIndexRevision(revision => revision + 1) + } + return + } + if (searchIndexTimer.current !== null) return + searchIndexTimer.current = setTimeout(() => { + searchIndexTimer.current = null + if (searchIndex.update(latestSearchLayouts.current)) { + setSearchIndexRevision(revision => revision + 1) + } + }, SEARCH_INDEX_THROTTLE_MS) + }, [searchIndex, searchLayouts]) + useEffect(() => () => { + if (searchIndexTimer.current !== null) clearTimeout(searchIndexTimer.current) + }, []) const streamingCells = useMemo( () => partialSearchTurns.flatMap(turn => turn.groups.flatMap(group => group.cells), ), [partialSearchTurns], ) - const partialSearchMatches = useMemo( - () => searchMatches(partialSearchTurns, searchQuery), - [partialSearchTurns, searchQuery], + const searchMatchRecordIds = useMemo( + () => searchIndex.search(searchQuery), + [searchIndex, searchIndexRevision, searchQuery], ) - const searchMatchIndexes = useMemo( - () => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches), - [finalizedSearchMatches, partialSearchMatches], - ) - const timelineRange = timelineSelection?.branchKey === currentBranch.key - ? timelineSelection.range - : null + const searchMatchIndexes = useMemo(() => { + if (searchMatchRecordIds === null) return null + const indexes = new Set() + for (const turns of searchLayouts) { + for (const turn of turns) { + for (const group of turn.groups) { + for (const cell of group.cells) { + if (searchMatchRecordIds.has(trajectoryRecordId(cell))) indexes.add(cell.index) + } + } + } + } + return indexes + }, [searchLayouts, searchMatchRecordIds]) + const timelineRange = timelineSelection const timelineFocusIndexes = useMemo( () => timelineRange === null ? null @@ -428,11 +357,8 @@ export function TrajectoryView({ } }, [timelineFocusIndexes]) const handleTimelineRangeChange = useCallback((range: TrajectoryTimeRange | null) => { - setTimelineSelection(range === null ? null : { - branchKey: currentBranch.key, - range, - }) - }, [currentBranch.key]) + setTimelineSelection(range) + }, []) const handleTimelineRecordSelect = useCallback((index: number) => { setTimelineSelection(null) setTimelineRecordSelection({ index }) @@ -518,11 +444,21 @@ export function TrajectoryView({ } const loadEarlierHistory = useCallback(() => { - const signal = historyControllerRef.current?.signal - return signal?.aborted === false - ? loadOlderHistory(signal) - : Promise.resolve(false) - }, [loadOlderHistory]) + return loadOlder() + }, [loadOlder]) + + const onExport = useCallback(() => { + if (exporting) return + setExporting(true) + setExportError(null) + void exportLog().then( + () => { setExporting(false) }, + (error: unknown) => { + setExportError(error instanceof Error ? error.message : String(error)) + setExporting(false) + }, + ) + }, [exportLog, exporting]) return (
@@ -543,7 +479,16 @@ export function TrajectoryView({ onToggleAllAssistants={toggleAllAssistants} searchQuery={searchQuery} onSearchQueryChange={setSearchQuery} + exporting={exporting} + onExport={onExport} + exportError={exportError} + t={t} /> + {exportError !== null && ( +
+ {exportError} +
+ )}
-} - -interface MutableBranch { - id: number - key: string - contexts: ConversationContext[] - latest: ConversationContext - nodes: Map - startSeq: number - retainedSurfaceSeqs: Set -} - -function isCompactionCheckpoint(node: ConversationNode): boolean { - if (node.kind !== 'context') return false - const source = node.source - return typeof source === 'object' - && source !== null - && 'kind' in source - && source.kind === 'plugin' - && 'plugin' in source - && source.plugin === 'compact' -} - -/** - * Join context generations across compaction/rewrite operations and split only at rewind. - * @param contexts - Append-only context generations from the runtime fold. - * @returns Rewind-delimited branches in creation order. - */ -export function deriveTrajectoryContextBranches( - contexts: readonly ConversationContext[], -): readonly TrajectoryContextBranch[] { - const mutable: MutableBranch[] = [] - for (const context of contexts) { - const startsBranch = mutable.length === 0 || context.origin === 'rewind' - if (startsBranch) { - const previous = mutable.at(-1) - const retainedSurfaceSeqs = new Set( - context.nodes - .filter(node => - context.originSeq !== undefined && node.seq < context.originSeq, - ) - .map(node => node.seq), - ) - const inheritedNodes = previous === undefined - ? [] - : [...previous.nodes.values()].filter(node => - retainedSurfaceSeqs.has(node.seq), - ) - mutable.push({ - id: context.id, - key: context.origin === 'rewind' && context.originSeq !== undefined - ? `rewind:${context.originSeq}` - : 'root', - contexts: [context], - latest: context, - nodes: new Map( - [...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))] - .map(node => [node.seq, node]), - ), - startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY, - retainedSurfaceSeqs, - }) - continue - } - const branch = mutable.at(-1) - if (branch === undefined) continue - branch.contexts.push(context) - branch.latest = context - for (const node of context.nodes) { - if (!isCompactionCheckpoint(node)) branch.nodes.set(node.seq, node) - } - } - return mutable.map(branch => ({ - id: branch.id, - key: branch.key, - contexts: branch.contexts, - latest: branch.latest, - nodes: [...branch.nodes.values()].sort((left, right) => left.seq - right.seq), - startSeq: branch.startSeq, - retainedSurfaceSeqs: branch.retainedSurfaceSeqs, - })) -} - -/** - * Test whether a provider request belongs to one rewind branch. - * @param branch - Branch carrying the exact inherited surface event seqs. - * @param request - Provider request to classify. - * @returns Whether the request began on this branch or produced a retained surface record. - */ -export function trajectoryBranchContainsRequest( - branch: TrajectoryContextBranch, - request: RequestView, -): boolean { - if (request.startSeq >= branch.startSeq) return true - return ( - request.resultSeq !== undefined - && branch.retainedSurfaceSeqs.has(request.resultSeq) - ) || ( - request.purpose === 'compaction' - && - request.replacementSeq !== undefined - && branch.retainedSurfaceSeqs.has(request.replacementSeq) - ) -} diff --git a/packages/client/ui-trajectory/src/client/export-log.ts b/packages/client/ui-trajectory/src/client/export-log.ts new file mode 100644 index 0000000000..3a25794d4d --- /dev/null +++ b/packages/client/ui-trajectory/src/client/export-log.ts @@ -0,0 +1,42 @@ +/** + * Session log export: browser download of the host-streamed ZIP. The archive + * itself is produced and streamed by the host (GET /api/session.export); this + * module only derives the download filename and triggers the browser save. + * @module + */ + +/** + * Collapse an untrusted session id into one safe path/filename segment. + * Distinct ids may collapse onto one segment (impossible for the host-minted + * UUIDs, so no uniqueness suffix is kept). + * @param id - the raw session id. + * @returns a filesystem-safe single segment. + */ +function safeSessionIdSegment(id: string): string { + return id.replace(/[^A-Za-z0-9_-]/g, '_') +} + +/** + * The export archive filename for one session (same convention the host's + * Content-Disposition uses). + * @param sessionId - the root session id. + * @returns the download filename. + */ +export function sessionLogZipFilename(sessionId: string): string { + return `dsh-session-${safeSessionIdSegment(sessionId)}.zip` +} + +/** + * Trigger a browser download of a blob response. + * @param blob - the response body to save (passed straight through, no copy). + * @param filename - the download filename. + */ +export function downloadBlob(blob: Blob, filename: string): void { + const url = URL.createObjectURL(blob) + const anchor = document.createElement('a') + anchor.href = url + anchor.download = filename + anchor.click() + // Revoke one tick later: some browsers read the blob URL after click(). + setTimeout(() => { URL.revokeObjectURL(url) }, 0) +} diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index a6b5a4282d..8337e060c9 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -2,16 +2,26 @@ * Browser trajectory plugin contributing one entry to the conversation view * slot without defining a service. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' +// Type-only: pulls the locale plugin's Context merge (ctx.locale). +import type {} from '@deepseek-ai/dsh-client-locale/client' // Type-only: the 'conversation.view' SlotMap row (declared by the slot's // owning package) must be in the program for the register calls to type. import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' +import { downloadBlob, sessionLogZipFilename } from './export-log.ts' +import { en, NS, zh } from './locales.ts' +import { registerTrajectoryAssistantDefinition } from './trajectory-assistant-definition.ts' +import { registerTrajectoryCompactionDefinitions } from './trajectory-compaction-definition.ts' +import { registerTrajectoryMessageDefinitions } from './trajectory-message-definitions.ts' +import { registerTrajectoryRequestHeaderDefinition } from './trajectory-request-header-definition.ts' +import { registerTrajectoryConversationView } from './trajectory-snapshot-builder.ts' +import { registerTrajectoryToolDefinition } from './trajectory-tool-definition.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' -/** Required services: the conversation view slot and independent history source. */ -export const inject = ['slots', 'sessionHistory'] +/** Required services: the conversation slot, registries, ordinary Session paging, and the locale service. */ +export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale'] /** * Client plugin body: register the trajectory view tab. The registration @@ -19,19 +29,54 @@ export const inject = ['slots', 'sessionHistory'] * @param ctx - client root context. */ export function apply(ctx: Context): void { + ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'ui-trajectory: dictionaries') + // Registration-time text (the view tab label) reads through the bound + // translate as a thunk, so it follows the active locale without + // re-registration. + const t = ctx.locale.bind(NS) const duration = createTrajectoryDurationStore() + registerTrajectoryMessageDefinitions(ctx) + registerTrajectoryRequestHeaderDefinition(ctx) + registerTrajectoryAssistantDefinition(ctx) + registerTrajectoryToolDefinition(ctx) + registerTrajectoryCompactionDefinitions(ctx) + registerTrajectoryConversationView(ctx) ctx.slots.inject('conversation.view', () => ctx.slots.register({ name: 'conversation.view', id: 'trajectory', order: 10, - label: 'Trajectory', + locale: NS, + label: () => t('view.trajectory'), inject: (sessionId: SessionId): TrajectoryViewInjected => { - const history = ctx.sessionHistory.source(sessionId) + const session = ctx.sessions.binding(sessionId)?.session + if (session === undefined) { + throw new Error(`ui-trajectory: session "${sessionId}" is unavailable`) + } return { - hooks: { history, duration }, - loadHistoryTail: signal => history.loadTail(signal), - loadOlderHistory: signal => history.loadOlder(signal), + hooks: { duration }, + loadOlder: async () => { + const before = session.getSnapshot().views.get('trajectory') + await session.loadOlder() + return session.getSnapshot().views.get('trajectory') !== before + }, setActualDuration: (value) => { duration.set(value) }, + exportLog: async () => { + // The host streams the ZIP (root + descendant artifacts verbatim) + // from GET /api/session.export; the browser downloads the response. + // A null origin (no-location Node contexts) falls back like the + // carrier's resolveBase so the URL stays valid. + const loc = (globalThis as { location?: { origin?: string } }).location + const origin = loc?.origin !== undefined && loc.origin !== 'null' ? loc.origin : 'http://dsh.internal' + const url = new URL('/api/session.export', origin) + url.searchParams.set('sessionId', sessionId) + url.searchParams.set('includeDescendants', 'true') + const response = await fetch(url) + if (!response.ok) { + const detail = await response.text().catch(() => '') + throw new Error(`Export failed: HTTP ${response.status}${detail === '' ? '' : ` ${detail}`}`) + } + downloadBlob(await response.blob(), sessionLogZipFilename(sessionId)) + }, } }, }, TrajectoryView)) diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 0d7a8c9e07..265d5ba034 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -5,6 +5,7 @@ import type { AssistantBlock, AssistantMessageNode, + ConversationLocation, ConversationSnapshot, RequestInspectionSnapshot, RequestPromptChange, @@ -12,7 +13,6 @@ import type { ToolCallBlock, ToolResultNode, } from '@deepseek-ai/dsh-client-runtime/client' -import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives' import type { TrajectoryCellProps, TrajectorySourceBlock, @@ -35,6 +35,7 @@ export interface TrajectoryTurnModel { /** Snapshot slice the trajectory view folds. */ export interface TrajectoryLayoutInput { nodes: ConversationSnapshot['nodes'] + eventLocations?: ReadonlyMap partial: ConversationSnapshot['partial'] runningCalls: ConversationSnapshot['runningCalls'] requests?: readonly RequestView[] @@ -70,12 +71,9 @@ interface TurnBucket { type AssistantRequestView = Extract type CompactionRequestView = Extract -const PREVIEW_SOURCE_CHARACTERS = 2_048 -const PREVIEW_OUTPUT_CHARACTERS = 512 - type InputNode = Extract< ConversationSnapshot['nodes'][number], - { kind: 'user' | 'context' } + { kind: 'user' | 'steering' | 'context' } > type OrderedLayoutEntry = @@ -110,10 +108,19 @@ function layoutEntryOrder(entry: OrderedLayoutEntry): number { function inputCellDetail(node: InputNode): Pick< TrajectoryCellProps, - 'text' | 'sourceSeq' | 'messageSource' | 'inputDetail' | 'sourceBlocks' | 'timeSeconds' | 'startedAt' + | 'text' + | 'previewMarkdown' + | 'sourceSeq' + | 'messageSource' + | 'inputDetail' + | 'sourceBlocks' + | 'timeSeconds' + | 'startedAt' > { + const previewMarkdown = previewContent(node.content) return { - text: summarizeContent(node.content), + text: '', + ...(previewMarkdown === undefined ? {} : { previewMarkdown }), sourceSeq: node.seq, messageSource: node.source, inputDetail: detailContent(node.content), @@ -130,12 +137,13 @@ function inputCellDetail(node: InputNode): Pick< */ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly TrajectoryTurnModel[] { const { - nodes, partial, runningCalls, requests = [], callSchemas, + nodes, eventLocations, partial, runningCalls, requests = [], callSchemas, } = input const resultByCall = indexResults(nodes) const callById = new Map(resultByCall) for (const call of runningCalls) callById.set(call.callId, call) const emittedCallIds = indexAssistantCallIds(nodes) + const followingAssistants = indexFollowingAssistants(nodes) const callStartById = new Map() for (const result of resultByCall.values()) { const startedAt = finiteTime(result.callTime) @@ -180,6 +188,19 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T } groups.push({ title, laid: [...laid] }) } + const pushStepInput = (turn: number, step: number, laid: readonly LaidCell[]) => { + if (laid.length === 0) return + const groups = bucket(turn).groups + const title = `Step ${step}` + const existing = groups.find(group => group.title === title) + if (existing === undefined) { + groups.push({ title, laid: [...laid] }) + return + } + const request = existing.laid.findIndex(entry => entry.cell.requestOnly === true) + if (request === -1) existing.laid.push(...laid) + else existing.laid.splice(request, 0, ...laid) + } const representedRequests = new Set() for (const node of nodes) { @@ -293,7 +314,10 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T ? request.error ?? 'Compaction failed' : request.summary === undefined ? 'Context compacted' - : summarizeContent(request.summary), + : '', + ...(request.status === 'complete' && request.summary !== undefined + ? previewContentProperty(request.summary) + : {}), sourceSeq: request.startSeq, ...(request.summary === undefined ? {} @@ -330,7 +354,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T if (node.kind === 'user') { // user/message has no turn on the wire; enclose it in the next assistant // (or partial) turn, else open the turn after the last assistant. - const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn) + const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn) pushMessage(turn, { absTime: finiteTime(node.time), cell: { @@ -343,6 +367,26 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T prevAbsTime = finiteTime(node.time) ?? prevAbsTime continue } + if (node.kind === 'steering') { + const placement = steeringPlacement( + followingAssistants[i], + partial, + lastAssistantTurn, + eventLocations?.get(node.seq), + ) + const laid = { + absTime: finiteTime(node.time), + cell: { + index: ++index, + kind: 'user' as const, + ...inputCellDetail(node), + }, + } + if (placement.step === undefined) pushMessage(placement.turn, laid) + else pushStepInput(placement.turn, placement.step, [laid]) + prevAbsTime = finiteTime(node.time) ?? prevAbsTime + continue + } if (node.kind === 'assistant') { const laidList = withSubCalls( expandAssistant(node, index + 1, prevAbsTime, resultByCall, callStartById, callById), @@ -356,7 +400,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T continue } if (node.kind === 'context') { - const turn = enclosingUserTurn(nodes, i, partial, lastAssistantTurn) + const turn = enclosingUserTurn(followingAssistants[i], partial, lastAssistantTurn) pushMessage(turn, { absTime: finiteTime(node.time), cell: { @@ -377,6 +421,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T if (node.kind === 'tool-result') { if (!emittedCallIds.has(node.callId)) { const toolName = node.call?.name + const resultPreview = summarizeResult(node) const laidList: LaidCell[] = [{ absTime: finiteTime(node.callTime ?? node.time), ...(toolName !== undefined ? { toolName } : {}), @@ -386,13 +431,13 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T index: ++index, kind: 'tool', sourceSeq: node.seq, - text: node.call !== null + ...(node.call !== null ? summarizeCall(node.call.name, node.call.argsRaw) - : summarizeResult(node), + : resultAsText(resultPreview)), ...(node.call !== null ? { inputDetail: node.call.argsRaw } : {}), outputDetail: detailResult(node), outputBlocks: node.content.map(block => sourceBlock(block)), - result: summarizeResult(node), + ...resultPreview, callId: node.callId, isError: node.isError, timeSeconds: durationSeconds(node.time, node.callTime), @@ -440,7 +485,7 @@ export function deriveTrajectoryLayout(input: TrajectoryLayoutInput): readonly T cell: { index: ++index, kind: 'tool', - text: summarizeCall(call.name, call.argsRaw), + ...summarizeCall(call.name, call.argsRaw), inputDetail: call.argsRaw, callId: call.callId, timeSeconds: null, @@ -650,11 +695,14 @@ function expandAssistant( recordId: `assistant\u0000${node.turn}\u0000${node.step}`, kind: 'message', sourceSeq: node.seq, - text: messageText !== '' - ? summarizeText(messageText) + text: messageText !== '' || thinkingText !== '' + ? '' + : summarizeAssistantActivity(node.blocks), + ...(messageText !== '' + ? { previewMarkdown: messageText } : thinkingText !== '' - ? summarizeText(thinkingText) - : summarizeAssistantActivity(node.blocks), + ? { previewMarkdown: thinkingText } + : {}), ...(messageText !== '' ? { outputDetail: messageText } : {}), ...(thinkingText !== '' ? { thinkingDetail: thinkingText } : {}), sourceBlocks: node.blocks.map(block => assistantSourceBlock(block)), @@ -681,6 +729,7 @@ function expandAssistant( : durationSeconds(result.time, result.callTime) const callAbs = finiteTime(callStarts.get(block.callId)) const call = calls.get(block.callId) + const resultPreview = result === undefined ? undefined : summarizeResult(result) out.push({ absTime: callAbs, toolName: block.name, @@ -688,14 +737,14 @@ function expandAssistant( ...(call === undefined ? {} : { subCalls: call.subCalls }), cell: { index: ++index, kind: 'tool', - text: summarizeCall(block.name, block.argsRaw), + ...summarizeCall(block.name, block.argsRaw), inputDetail: block.argsRaw, callId: block.callId, ...(result !== undefined ? { outputDetail: detailResult(result), outputBlocks: result.content.map(block => sourceBlock(block)), - result: summarizeResult(result), + ...resultPreview, isError: result.isError, } : {}), @@ -808,22 +857,53 @@ function stringifySourceValue(value: unknown): string { * in-flight partial, else the turn after the last finalized assistant (or 1). */ function enclosingUserTurn( - nodes: ConversationSnapshot['nodes'], - userIndex: number, + followingAssistant: AssistantMessageNode | undefined, partial: ConversationSnapshot['partial'], lastAssistantTurn: number | null, ): number { - for (let i = userIndex + 1; i < nodes.length; i++) { - const n = nodes[i] - /* v8 ignore next -- dense-array guard: i stays within nodes.length, so the undefined arm needs a sparse array no caller builds. */ - if (n === undefined) continue - if (n.kind === 'assistant') return n.turn - } + if (followingAssistant !== undefined) return followingAssistant.turn if (partial !== null) return partial.turn if (lastAssistantTurn !== null) return lastAssistantTurn + 1 return 1 } +function steeringPlacement( + followingAssistant: AssistantMessageNode | undefined, + partial: ConversationSnapshot['partial'], + lastAssistantTurn: number | null, + location: ConversationLocation | undefined, +): { turn: number; step?: number } { + if (location?.kind === 'step') { + return { turn: location.turn.turn, step: location.step.step } + } + const locatedTurn = location?.kind === 'turn' ? location.turn.turn : undefined + if (followingAssistant !== undefined + && (locatedTurn === undefined || followingAssistant.turn === locatedTurn)) { + return { + turn: followingAssistant.turn, + ...(followingAssistant.step > 0 ? { step: followingAssistant.step } : {}), + } + } + if (partial !== null && (locatedTurn === undefined || partial.turn === locatedTurn)) { + return { turn: partial.turn, ...(partial.step > 0 ? { step: partial.step } : {}) } + } + if (locatedTurn !== undefined) return { turn: locatedTurn } + return { turn: lastAssistantTurn ?? 1 } +} + +function indexFollowingAssistants( + nodes: ConversationSnapshot['nodes'], +): readonly (AssistantMessageNode | undefined)[] { + const following = new Array(nodes.length) + let assistant: AssistantMessageNode | undefined + for (let index = nodes.length - 1; index >= 0; index--) { + following[index] = assistant + const node = nodes[index] + if (node?.kind === 'assistant') assistant = node + } + return following +} + function enclosingPromptTurn( nodes: ConversationSnapshot['nodes'], seq: number, @@ -919,6 +999,7 @@ function expandSubCalls( let index = startIndex for (const sub of subs) { const settled = 'kind' in sub + const resultPreview = settled ? summarizeResult(sub) : undefined const laid: LaidCell = { absTime: settled ? finiteTime(sub.callTime ?? sub.time) : finiteTime(sub.time), toolName: settled ? sub.call?.name ?? sub.callId : sub.name, @@ -927,9 +1008,11 @@ function expandSubCalls( index: ++index, kind: 'subtool', callId: sub.callId, - text: settled - ? (sub.call !== null ? summarizeCall(sub.call.name, sub.call.argsRaw) : summarizeResult(sub)) - : summarizeCall(sub.name, sub.argsRaw), + ...(settled + ? (sub.call !== null + ? summarizeCall(sub.call.name, sub.call.argsRaw) + : resultAsText(resultPreview)) + : summarizeCall(sub.name, sub.argsRaw)), ...(settled ? (sub.call !== null ? { inputDetail: sub.call.argsRaw } : {}) : { inputDetail: sub.argsRaw }), @@ -937,7 +1020,7 @@ function expandSubCalls( ? { outputDetail: detailResult(sub), outputBlocks: sub.content.map(block => sourceBlock(block)), - result: summarizeResult(sub), + ...resultPreview, isError: sub.isError, } : {}), @@ -958,22 +1041,39 @@ function expandSubCalls( return out } -function summarizeCall(name: string, argsRaw: string): string { - const args = trajectoryPreviewText(argsRaw) - if (args === '') return name - return `${name} · ${args}` +function summarizeCall( + name: string, + argsRaw: string, +): Pick { + return { + text: name, + ...(argsRaw === '' ? {} : { previewMarkdown: argsRaw }), + } } -function summarizeResult(node: ToolResultNode): string { +function summarizeResult( + node: ToolResultNode, +): Pick { if (node.isError) { - return node.error?.code ?? 'error' + return { result: node.error?.code ?? 'error' } } for (const block of node.content) { if (block.type === 'text' && typeof block.text === 'string' && block.text !== '') { - return summarizeText(block.text) + return { result: '', resultPreviewMarkdown: block.text } } } - return 'No output' + return { result: 'No output' } +} + +function resultAsText( + result: Pick | undefined, +): Pick { + return { + text: result?.result ?? '', + ...(result?.resultPreviewMarkdown === undefined + ? {} + : { previewMarkdown: result.resultPreviewMarkdown }), + } } function detailResult(node: ToolResultNode): string { @@ -1009,28 +1109,18 @@ function detailReasoning(content: readonly { type: string; text?: string }[]): s .join('\n') } -function summarizeContent(content: readonly { type: string; text?: string }[]): string { +function previewContent( + content: readonly { type: string; text?: string }[], +): string | undefined { for (const block of content) { - if (block.type === 'text' && typeof block.text === 'string') return summarizeText(block.text) + if (block.type === 'text' && typeof block.text === 'string') return block.text } - return '' + return undefined } -function summarizeText(text: string): string { - return trajectoryPreviewText(text) -} - -/** - * Build a bounded one-line ledger preview without parsing the complete Markdown document. - * Full source remains on the cell for the inspector. - * @param text - Untrusted message, reasoning, payload, or result text. - * @returns A compact preview capped independently from the retained source. - */ -export function trajectoryPreviewText(text: string): string { - const source = text.slice(0, PREVIEW_SOURCE_CHARACTERS) - const compact = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim() - const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd() - return source.length < text.length || preview.length < compact.length - ? `${preview}…` - : preview +function previewContentProperty( + content: readonly { type: string; text?: string }[], +): Pick { + const previewMarkdown = previewContent(content) + return previewMarkdown === undefined ? {} : { previewMarkdown } } diff --git a/packages/client/ui-trajectory/src/client/locales.ts b/packages/client/ui-trajectory/src/client/locales.ts new file mode 100644 index 0000000000..b226974160 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/locales.ts @@ -0,0 +1,76 @@ +/** `trajectory` namespace dictionaries (view tab label + toolbar strings). */ + +/** Dictionary namespace owned by this plugin. */ +export const NS = 'trajectory' + +/** The trajectory dictionary key set (the source of truth for both locales). */ +export type TrajectoryKey = + | 'view.trajectory' + | 'toolbar.aria' + | 'toolbar.duration' + | 'toolbar.useActualDuration' + | 'toolbar.useEqualWidth' + | 'toolbar.actualTime' + | 'toolbar.turns' + | 'toolbar.expandTurns' + | 'toolbar.collapseTurns' + | 'toolbar.calls' + | 'toolbar.expandCalls' + | 'toolbar.collapseCalls' + | 'toolbar.export' + | 'toolbar.exportAria' + | 'toolbar.exporting' + | 'toolbar.exportTitle' + | 'toolbar.search' + | 'toolbar.searchPlaceholder' + +declare module '@deepseek-ai/dsh-client-ui-slots' { + interface LocaleNamespaceMap { + /** The trajectory view tab label and toolbar strings. */ + 'trajectory': TrajectoryKey + } +} + +/** Simplified Chinese dictionary (the key-set source of truth). */ +export const zh: Record = { + 'view.trajectory': '轨迹', + 'toolbar.aria': '轨迹工具栏', + 'toolbar.duration': 'Duration', + 'toolbar.useActualDuration': 'Use actual duration', + 'toolbar.useEqualWidth': 'Use equal-width operations', + 'toolbar.actualTime': '实际时间', + 'toolbar.turns': 'Turns', + 'toolbar.expandTurns': 'Expand turns', + 'toolbar.collapseTurns': 'Collapse turns', + 'toolbar.calls': 'Calls', + 'toolbar.expandCalls': 'Expand calls', + 'toolbar.collapseCalls': 'Collapse calls', + 'toolbar.export': 'Export', + 'toolbar.exportAria': 'Export session log', + 'toolbar.exporting': 'Exporting…', + 'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)', + 'toolbar.search': '搜索轨迹', + 'toolbar.searchPlaceholder': '搜索', +} + +/** English dictionary. */ +export const en: Record = { + 'view.trajectory': 'Trajectory', + 'toolbar.aria': 'Trajectory toolbar', + 'toolbar.duration': 'Duration', + 'toolbar.useActualDuration': 'Use actual duration', + 'toolbar.useEqualWidth': 'Use equal-width operations', + 'toolbar.actualTime': 'Actual time', + 'toolbar.turns': 'Turns', + 'toolbar.expandTurns': 'Expand turns', + 'toolbar.collapseTurns': 'Collapse turns', + 'toolbar.calls': 'Calls', + 'toolbar.expandCalls': 'Expand calls', + 'toolbar.collapseCalls': 'Collapse calls', + 'toolbar.export': 'Export', + 'toolbar.exportAria': 'Export session log', + 'toolbar.exporting': 'Exporting…', + 'toolbar.exportTitle': 'Export session log (ZIP, includes subagents)', + 'toolbar.search': 'Search trajectory', + 'toolbar.searchPlaceholder': 'Search', +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts new file mode 100644 index 0000000000..16b61f6d53 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -0,0 +1,405 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { + AssistantBlock, AssistantMessageNode, ConversationLocation, ConversationMatch, + ConversationNodeContext, ConversationNodeDefinition, PartialAssistant, RequestView, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + displayFailureMessage, emptyAssistantBlock, isTokenDelta, toAssistantBlock, + toAssistantBlocks, +} from '@deepseek-ai/dsh-client-runtime/client' +import { trajectoryNode } from './trajectory-definition-common.ts' + +/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event + * state machines independent; see ../../../../../.agents/notes/implemented/ + * architecture/2026-08-09-client-conversation-node-assembly.md. */ +interface UsageValue { + readonly inputTokens: number + readonly outputTokens: number + readonly cacheReadTokens?: number + readonly cacheWriteTokens?: number + readonly reasoningTokens?: number +} + +interface RetryValue { + readonly message: string + readonly retry: number + readonly maxRetries?: number + readonly delayMs: number +} + +interface AssistantState { + readonly turn: number + readonly step: number + readonly startSeq: number + readonly startTime: number + readonly started: boolean + readonly sawChunk: boolean + readonly blocks: readonly (AssistantBlock | undefined)[] + readonly firstVisibleSeq: number | undefined + readonly firstVisibleTime: number | undefined + readonly firstTokenTime: number | undefined + readonly final: ConversationMatch | undefined + readonly usage: UsageValue | undefined + readonly retry: RetryValue | undefined + readonly stepEnd: ConversationMatch | undefined +} + +function initialState( + turn: number, + step: number, + startSeq: number, + startTime: number, + started: boolean, +): AssistantState { + return { + turn, + step, + startSeq, + startTime, + started, + sawChunk: false, + blocks: [], + firstVisibleSeq: undefined, + firstVisibleTime: undefined, + firstTokenTime: undefined, + final: undefined, + usage: undefined, + retry: undefined, + stepEnd: undefined, + } +} + +function compactBlocks(blocks: readonly (AssistantBlock | undefined)[]): AssistantBlock[] { + return blocks.filter((block): block is AssistantBlock => block !== undefined) +} + +function hasVisibleContent(blocks: readonly AssistantBlock[]): boolean { + return blocks.some((block) => { + if (block.kind === 'tool-call') return false + if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== '' + return true + }) +} + +function hasInterruptionEvidence(blocks: readonly AssistantBlock[]): boolean { + return blocks.some((block) => { + if (block.kind === 'text' || block.kind === 'reasoning') return block.text.trim() !== '' + return true + }) +} + +function addUsage(current: UsageValue | undefined, next: UsageValue): UsageValue { + return { + inputTokens: (current?.inputTokens ?? 0) + next.inputTokens, + outputTokens: (current?.outputTokens ?? 0) + next.outputTokens, + ...(current?.cacheReadTokens === undefined && next.cacheReadTokens === undefined + ? {} + : { cacheReadTokens: (current?.cacheReadTokens ?? 0) + (next.cacheReadTokens ?? 0) }), + ...(current?.cacheWriteTokens === undefined && next.cacheWriteTokens === undefined + ? {} + : { cacheWriteTokens: (current?.cacheWriteTokens ?? 0) + (next.cacheWriteTokens ?? 0) }), + ...(current?.reasoningTokens === undefined && next.reasoningTokens === undefined + ? {} + : { reasoningTokens: (current?.reasoningTokens ?? 0) + (next.reasoningTokens ?? 0) }), + } +} + +function updateChunk(state: AssistantState, match: ConversationMatch): AssistantState { + if (match.event.type !== 'assistant/chunk') return state + const chunk = match.event.data.chunk + if (chunk.type === 'usage') { + return { ...state, sawChunk: true, usage: addUsage(state.usage, chunk.usage) } + } + const blocks = [...state.blocks] + switch (chunk.type) { + case 'block-start': + blocks[chunk.index] = emptyAssistantBlock(chunk.blockType) + break + case 'text-delta': { + const previous = blocks[chunk.index] + blocks[chunk.index] = { + kind: 'text', + text: (previous?.kind === 'text' ? previous.text : '') + chunk.text, + } + break + } + case 'reasoning-delta': { + const previous = blocks[chunk.index] + blocks[chunk.index] = { + kind: 'reasoning', + text: (previous?.kind === 'reasoning' ? previous.text : '') + chunk.text, + } + break + } + case 'tool-call-delta': { + const previous = blocks[chunk.index] + const base = previous?.kind === 'tool-call' + ? previous + : { kind: 'tool-call' as const, callId: '', name: '', argsRaw: '' } + blocks[chunk.index] = { + kind: 'tool-call', + callId: base.callId || String(chunk.id), + name: chunk.name ?? base.name, + argsRaw: base.argsRaw + chunk.argumentsDelta, + } + break + } + case 'block-end': + blocks[chunk.index] = toAssistantBlock(chunk.block) + break + default: + return { ...state, sawChunk: true } + } + const visible = hasVisibleContent(compactBlocks(blocks)) + return { + ...state, + sawChunk: true, + blocks, + ...(visible && state.firstVisibleSeq === undefined + ? { firstVisibleSeq: match.event.seq, firstVisibleTime: match.event.time } + : {}), + ...(isTokenDelta(chunk) && state.firstTokenTime === undefined + ? { firstTokenTime: match.event.time } + : {}), + } +} + +function closedBoundary( + context: ConversationNodeContext, +): { seq: number; time: number } | undefined { + if (context.state?.stepEnd?.event.type === 'step/end') return context.state.stepEnd.event + const location: ConversationLocation | undefined = context.start?.location + ?? context.matches.at(-1)?.location + if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end + if ((location?.kind === 'step' || location?.kind === 'turn') + && location.turn.status === 'closed') return location.turn.end + return undefined +} + +function fallbackState(context: ConversationNodeContext): AssistantState | undefined { + let state: AssistantState | undefined + for (const match of context.matches) { + const event = match.event + if (event.type === 'assistant/chunk') { + state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) + state = updateChunk(state, match) + } else if (event.type === 'assistant/message') { + state ??= initialState(event.data.turn, event.data.step, event.seq, event.time, false) + state = { + ...state, + blocks: toAssistantBlocks(event.data.message.content), + final: match, + usage: state.usage ?? event.data.usage, + } + } else if (event.type === 'step/end' && state !== undefined) { + state = { ...state, stepEnd: match } + } + } + return state +} + +function finalNode( + state: AssistantState, + context: ConversationNodeContext, +): AssistantMessageNode | undefined { + const final = state.final + if (final?.event.type === 'assistant/message') { + const event = final.event + return { + kind: 'assistant', + seq: event.seq, + time: event.time, + turn: state.turn, + step: state.step, + blocks: toAssistantBlocks(event.data.message.content), + usage: event.data.usage, + provenance: { + provider: event.data.message.source.provider, + model: event.data.message.source.model, + }, + timing: { + stepStartTime: state.started ? state.startTime : null, + firstTokenTime: state.firstTokenTime ?? null, + completedTime: event.time, + }, + } + } + const boundary = closedBoundary(context) + const blocks = compactBlocks(state.blocks) + if (boundary === undefined || !hasInterruptionEvidence(blocks)) return undefined + return { + kind: 'assistant', + seq: boundary.seq - 0.9, + time: boundary.time, + turn: state.turn, + step: state.step, + blocks, + interrupted: true, + } +} + +function assistantRequest( + state: AssistantState, + node: AssistantMessageNode | undefined, + boundary: { seq: number; time: number } | undefined, +): Extract | undefined { + if (!state.started) return undefined + const status = node !== undefined && node.interrupted !== true + ? 'complete' + : state.retry !== undefined || boundary !== undefined ? 'error' : 'running' + return { + purpose: 'assistant', + startSeq: state.startSeq, + turn: state.turn, + step: state.step, + startedAt: state.startTime, + completedAt: node?.time ?? boundary?.time ?? null, + status, + ...(state.retry === undefined + ? {} + : { + error: state.retry.message, + retry: state.retry.retry, + ...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }), + retryDelayMs: state.retry.delayMs, + }), + ...(node === undefined || node.interrupted === true + ? {} + : { + resultSeq: node.seq, + ...(node.provenance === undefined ? {} : { provenance: node.provenance }), + }), + ...(state.usage === undefined ? {} : { usage: state.usage }), + } +} + +/** Trajectory-owned Assistant streaming, settlement, and request lifecycle. */ +const trajectoryAssistantDefinition: ConversationNodeDefinition = { + kind: 'trajectory-assistant-step', + target: 'trajectory', + match: (event) => { + if (event.type === 'step/start') { + return { id: `${event.data.turn}:${event.data.step}`, role: 'start' } + } + if (event.type === 'assistant/chunk' + || event.type === 'assistant/message' + || event.type === 'llm/retry' + || event.type === 'step/end') { + return { id: `${event.data.turn}:${event.data.step}`, role: 'update' } + } + return null + }, + start: (_context, match) => { + if (match.event.type !== 'step/start') { + throw new Error('trajectory-assistant-step start requires step/start') + } + return initialState( + match.event.data.turn, + match.event.data.step, + match.event.seq, + match.event.time, + true, + ) + }, + update: (context, match) => { + if (match.event.type === 'assistant/chunk') return updateChunk(context.state, match) + if (match.event.type === 'assistant/message') { + return { + ...context.state, + blocks: toAssistantBlocks(match.event.data.message.content), + final: match, + usage: context.state.usage ?? match.event.data.usage, + } + } + if (match.event.type === 'step/end') return { ...context.state, stepEnd: match } + if (match.event.type !== 'llm/retry') return context.state + const data = match.event.data + return { + ...initialState( + context.state.turn, + context.state.step, + context.state.startSeq, + context.state.startTime, + true, + ), + firstTokenTime: context.state.firstTokenTime, + usage: context.state.usage, + retry: { + message: displayFailureMessage(data.failure), + retry: data.retry, + ...(data.mode === 'normal' ? { maxRetries: data.maxRetries } : {}), + delayMs: data.delayMs, + }, + } + }, + publication: (match) => { + if (match.event.type === 'step/start') return 'none' + if (match.event.type !== 'assistant/chunk') return 'immediate' + const type = match.event.data.chunk.type + return type === 'usage' || type === 'finish' ? 'none' : 'animation-frame' + }, + buildViewNode: (context) => { + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const node = finalNode(state, context) + const boundary = closedBoundary(context) + const partial: PartialAssistant | null = node === undefined && boundary === undefined && state.sawChunk + ? { turn: state.turn, step: state.step, blocks: compactBlocks(state.blocks) } + : null + const request = assistantRequest(state, node, boundary) + if (node === undefined && partial === null && request === undefined) return null + return trajectoryNode(context, state.startSeq, { + kind: 'assistant', + ...(node === undefined ? {} : { node }), + partial, + ...(request === undefined ? {} : { request }), + }) + }, +} + +interface TurnEndState { + readonly turn: number + readonly seq: number + readonly time: number + readonly error?: string +} + +const trajectoryTurnEndDefinition: ConversationNodeDefinition = { + kind: 'trajectory-turn-end', + target: 'trajectory', + match: event => event.type === 'turn/end' + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match) => { + if (match.event.type !== 'turn/end') { + throw new Error('trajectory-turn-end start requires turn/end') + } + const reason = match.event.data.reason + return { + turn: match.event.data.turn, + seq: match.event.seq, + time: match.event.time, + ...(reason.kind === 'error' ? { error: displayFailureMessage(reason.error) } : {}), + } + }, + update: context => context.state, + buildViewNode: context => context.state === undefined + ? null + : trajectoryNode(context, context.state.seq, { + kind: 'turn-end', + turn: context.state.turn, + time: context.state.time, + ...(context.state.error === undefined ? {} : { error: context.state.error }), + }), +} +/* jscpd:ignore-end */ + +/** + * Register the Trajectory Assistant lifecycle. + * + * @param ctx - Plugin context receiving the Definitions. + */ +export function registerTrajectoryAssistantDefinition(ctx: Context): void { + ctx.conversationEvents.register(trajectoryAssistantDefinition) + ctx.conversationEvents.register(trajectoryTurnEndDefinition) +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts new file mode 100644 index 0000000000..a822e4e5bb --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts @@ -0,0 +1,143 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { + ConversationMatch, ConversationNodeDefinition, RequestView, +} from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-compact/types' +import { trajectoryNode } from './trajectory-definition-common.ts' + +interface CompactionState { + readonly start: ConversationMatch + readonly summary?: ConversationMatch + readonly end?: ConversationMatch + readonly checkpoint?: ConversationMatch +} + +function checkpointId( + event: Parameters[0], +): string | undefined { + if (event.type !== 'user/message') return undefined + const source = event.data.source as unknown as { + readonly kind?: unknown + readonly plugin?: unknown + readonly compactionId?: unknown + } + return source.kind === 'plugin' && source.plugin === 'compact' + && typeof source.compactionId === 'string' && source.compactionId !== '' + ? source.compactionId + : undefined +} + +function eventCompactionId( + event: Parameters[0], +): string | undefined { + if (event.type !== 'compact/start' + && event.type !== 'compact/summary' + && event.type !== 'compact/end') return undefined + const value: unknown = event.data.compactionId + return typeof value === 'string' && value !== '' ? value : undefined +} + +function requestFromState( + state: CompactionState, +): Extract | undefined { + const start = state.start.event + if (start.type !== 'compact/start') return undefined + const summary = state.summary?.event + const end = state.end?.event + const checkpoint = state.checkpoint?.event + return { + purpose: 'compaction', + startSeq: start.seq, + turn: start.data.turn, + step: 0, + startedAt: start.time, + completedAt: end?.type === 'compact/end' ? end.time : null, + status: end?.type !== 'compact/end' + ? 'running' + : end.data.error === undefined ? 'complete' : 'error', + ...(end?.type === 'compact/end' && end.data.error !== undefined + ? { error: end.data.error } + : {}), + ...(summary?.type !== 'compact/summary' + ? {} + : { + resultSeq: summary.seq, + summary: summary.data.summary, + ...(summary.data.rawOutput === undefined ? {} : { rawOutput: summary.data.rawOutput }), + provenance: { provider: summary.data.provider, model: summary.data.model }, + requestConfig: { + provider: summary.data.provider, + model: summary.data.model, + purpose: 'compaction', + ...(summary.data.maxTokens === undefined ? {} : { maxTokens: summary.data.maxTokens }), + }, + ...(summary.data.usage === undefined ? {} : { usage: summary.data.usage }), + }), + ...(checkpoint?.type === 'user/message' ? { replacementSeq: checkpoint.seq } : {}), + } +} + +const trajectoryCompactionDefinition: ConversationNodeDefinition = { + kind: 'trajectory-compaction', + target: 'trajectory', + match: (event) => { + const compactId = eventCompactionId(event) + if (compactId !== undefined) { + return { id: compactId, role: event.type === 'compact/start' ? 'start' : 'update' } + } + const checkpoint = checkpointId(event) + return checkpoint === undefined ? null : { id: checkpoint, role: 'update' } + }, + start: (_context, match) => { + if (match.event.type !== 'compact/start') { + throw new Error('trajectory-compaction start requires compact/start') + } + return { start: match } + }, + update: (context, match) => { + if (match.event.type === 'compact/summary') return { ...context.state, summary: match } + if (match.event.type === 'compact/end') return { ...context.state, end: match } + return checkpointId(match.event) === undefined + ? context.state + : { ...context.state, checkpoint: match } + }, + buildViewNode: (context) => { + if (context.state === undefined) return null + const request = requestFromState(context.state) + return request === undefined + ? null + : trajectoryNode(context, request.startSeq, { kind: 'compaction', request }) + }, +} + +interface SessionEndState { + readonly seq: number + readonly time: number +} + +const trajectorySessionEndDefinition: ConversationNodeDefinition = { + kind: 'trajectory-session-end', + target: 'trajectory', + match: event => event.type === 'session/end-seed' + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match) => ({ seq: match.event.seq, time: match.event.time }), + update: context => context.state, + buildViewNode: context => context.state === undefined + ? null + : trajectoryNode(context, context.state.seq, { + kind: 'session-end', + seq: context.state.seq, + time: context.state.time, + }), +} + +/** + * Register Trajectory compaction requests and session boundaries. + * + * @param ctx - Plugin context receiving the Definitions. + */ +export function registerTrajectoryCompactionDefinitions(ctx: Context): void { + ctx.conversationEvents.register(trajectoryCompactionDefinition) + ctx.conversationEvents.register(trajectorySessionEndDefinition) +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-contract.ts b/packages/client/ui-trajectory/src/client/trajectory-contract.ts new file mode 100644 index 0000000000..7f1c1feea9 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-contract.ts @@ -0,0 +1,75 @@ +import type { + AssistantMessageNode, ConversationLocation, ConversationNode, + ConversationPromptSnapshot, ConversationViewNode, PartialAssistant, + RequestPromptChange, RequestView, RunningToolCall, ToolCallBlock, +} from '@deepseek-ai/dsh-client-runtime/client' + +/** Request-header facts retained by the Trajectory target. */ +export interface TrajectoryRequestHeaderState { + readonly seq: number + readonly time: number + readonly prompt: ConversationPromptSnapshot + readonly change?: RequestPromptChange + readonly location: ConversationLocation +} + +/** One independently assembled contribution to the legacy Trajectory ledger. */ +export type TrajectoryContribution = + | { + readonly kind: 'node' + readonly node: ConversationNode + } + | { + readonly kind: 'assistant' + readonly node?: AssistantMessageNode + readonly partial: PartialAssistant | null + readonly request?: Extract + } + | { + readonly kind: 'tool' + readonly root: ToolCallBlock + } + | { + readonly kind: 'request-header' + readonly header: TrajectoryRequestHeaderState + } + | { + readonly kind: 'compaction' + readonly request: Extract + } + | { + readonly kind: 'session-end' + readonly seq: number + readonly time: number + } + | { + readonly kind: 'turn-end' + readonly turn: number + readonly time: number + readonly error?: string + } + +/** Target envelope consumed by the Trajectory snapshot builder. */ +export interface TrajectoryConversationViewNode extends ConversationViewNode { + readonly target: 'trajectory' + readonly anchorSeq: number + readonly location: ConversationLocation + readonly data: TrajectoryContribution +} + +/** Stage-oriented Trajectory data assembled from registered business Contexts. */ +export interface TrajectorySnapshot { + readonly eventNodes: readonly ConversationNode[] + readonly eventLocations: ReadonlyMap + readonly requests: readonly RequestView[] + readonly callSchemas: ReadonlyMap + readonly partial: PartialAssistant | null + readonly runningCalls: readonly RunningToolCall[] +} + +declare module '@deepseek-ai/dsh-client-runtime/client' { + interface ConversationViewSnapshotMap { + /** Independently assembled data consumed by the Trajectory view. */ + trajectory: TrajectorySnapshot + } +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts new file mode 100644 index 0000000000..d55d5ca542 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts @@ -0,0 +1,28 @@ +import type { ConversationNodeContext } from '@deepseek-ai/dsh-client-runtime/client' +import type { + TrajectoryContribution, TrajectoryConversationViewNode, +} from './trajectory-contract.ts' + +/** + * Wrap one contribution in the Engine-owned target envelope. + * + * @param context - Context that owns the contribution identity. + * @param anchorSeq - Sequence used to order the contribution. + * @param data - Trajectory-specific contribution payload. + * @returns The contribution wrapped as a Trajectory view node. + */ +export function trajectoryNode( + context: ConversationNodeContext, + anchorSeq: number, + data: TrajectoryContribution, +): TrajectoryConversationViewNode { + return { + key: context.key, + kind: context.kind, + id: context.id, + target: 'trajectory', + anchorSeq, + location: context.start?.location ?? { kind: 'unresolved' }, + data, + } +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts new file mode 100644 index 0000000000..4139a318db --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -0,0 +1,122 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { + ContextMessageNode, ConversationNodeDefinition, ConversationPreviousContext, + SteeringMessageNode, UserMessageNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import { + contextForm, contextProvenance, +} from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-agent/types' +import { trajectoryNode } from './trajectory-definition-common.ts' + +/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event + * state machines independent; see ../../../../../.agents/notes/implemented/ + * architecture/2026-08-09-client-conversation-node-assembly.md. */ +interface InboxIdentity { + readonly id: string +} + +interface InboxSplice { + readonly start: number + readonly removedCount?: number + readonly inserted: readonly InboxIdentity[] + readonly outcome?: 'canceled' +} + +interface InboxState { + readonly pending: readonly InboxIdentity[] + readonly claimed: ReadonlySet +} + +type MessageNode = UserMessageNode | SteeringMessageNode | ContextMessageNode + +function applySplice( + previous: ConversationPreviousContext | undefined, + splice: InboxSplice, +): InboxState { + const pending = [...(previous?.state.pending ?? [])] + const claimed = new Set(previous?.state.claimed ?? []) + const removed = pending.splice(splice.start, splice.removedCount ?? 0, ...splice.inserted) + for (const identity of splice.inserted) claimed.delete(identity.id) + if (splice.outcome !== 'canceled') { + for (const identity of removed) claimed.add(identity.id) + } + return { pending, claimed } +} + +const trajectoryInboxDefinition: ConversationNodeDefinition = { + kind: 'trajectory-inbox-next-step', + match: event => event.type === 'agent/inbox/spliced' + && event.data.target === 'next-step' + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match, reader) => { + if (match.event.type !== 'agent/inbox/spliced') { + throw new Error('trajectory-inbox-next-step start requires agent/inbox/spliced') + } + return applySplice( + reader.previous('trajectory-inbox-next-step'), + match.event.data, + ) + }, + update: context => context.state, + publication: () => 'none', +} + +const trajectoryMessageDefinition: ConversationNodeDefinition = { + kind: 'trajectory-input-message', + target: 'trajectory', + match: event => event.type === 'user/message' + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match, reader) => { + if (match.event.type !== 'user/message') { + throw new Error('trajectory-input-message start requires user/message') + } + const event = match.event + if (event.data.source.kind !== 'user') { + return { + kind: 'context', + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + provenance: contextProvenance(event.data.source), + form: contextForm(event.data.source), + } + } + const claimed = reader.previous('trajectory-inbox-next-step') + ?.state.claimed.has(String(event.data.id)) === true + return claimed + ? { + kind: 'steering', + messageId: event.data.id, + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + } + : { + kind: 'user', + seq: event.seq, + time: event.time, + content: event.data.content, + source: event.data.source, + } + }, + update: context => context.state, + buildViewNode: context => context.state === undefined + ? null + : trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }), +} +/* jscpd:ignore-end */ + +/** + * Register Trajectory-owned inbox classification and message records. + * + * @param ctx - Plugin context receiving the Definitions. + */ +export function registerTrajectoryMessageDefinitions(ctx: Context): void { + ctx.conversationEvents.register(trajectoryInboxDefinition) + ctx.conversationEvents.register(trajectoryMessageDefinition) +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-preview.ts b/packages/client/ui-trajectory/src/client/trajectory-preview.ts new file mode 100644 index 0000000000..840fc2381e --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-preview.ts @@ -0,0 +1,20 @@ +/** Bounded Markdown-to-text projection shared by trajectory consumers. */ + +import { extractMarkdownPlainText } from '@deepseek-ai/dsh-client-ui-primitives' + +const PREVIEW_SOURCE_CHARACTERS = 2_048 +const PREVIEW_OUTPUT_CHARACTERS = 512 + +/** + * Build a bounded one-line preview without parsing the complete Markdown document. + * @param text - Untrusted message, reasoning, payload, or result text. + * @returns A compact preview capped independently from the retained source. + */ +export function trajectoryPreviewText(text: string): string { + const source = text.slice(0, PREVIEW_SOURCE_CHARACTERS) + const compact = extractMarkdownPlainText(source).replace(/\s+/g, ' ').trim() + const preview = compact.slice(0, PREVIEW_OUTPUT_CHARACTERS).trimEnd() + return source.length < text.length || preview.length < compact.length + ? `${preview}…` + : preview +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-record.ts b/packages/client/ui-trajectory/src/client/trajectory-record.ts index 2c3f2a1a83..e4cd6e6e02 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-record.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-record.ts @@ -40,8 +40,10 @@ export interface TrajectoryCellProps extends HTMLAttributes { /** Projection-stable identity when no single source event owns the record lifecycle. */ recordId?: string kind: TrajectoryCellKind - /** Single-line summary; CSS ellipsis when it overflows. */ + /** Non-Markdown summary or prefix; CSS ellipsis when it overflows. */ text: string + /** Raw Markdown source converted into the single-line summary at its consumer. */ + previewMarkdown?: string /** Whether this user record opens a new model turn. */ opensTurn?: boolean /** Source session-event seq for cross-record navigation. */ @@ -71,6 +73,8 @@ export interface TrajectoryCellProps extends HTMLAttributes { assistantMetrics?: AssistantMetricDetail /** Tool-only result summary paired with the call in the same record. */ result?: string + /** Raw Markdown source converted into the tool-result summary at its consumer. */ + resultPreviewMarkdown?: string /** Tool call id used to link message source blocks to tool records. */ callId?: string /** Tool-only result failure state. */ @@ -112,7 +116,8 @@ export function trajectoryRecordId(cell: TrajectoryCellProps): string { */ export function formatDurationMillis(milliseconds: number | null): string { if (milliseconds === null || !Number.isFinite(milliseconds)) return '—' - return `${Math.round(milliseconds).toLocaleString('en-US')} ms` + const integer = String(Math.round(milliseconds)) + return `${integer.replace(/\B(?=(\d{3})+(?!\d))/g, ',')} ms` } /** diff --git a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts new file mode 100644 index 0000000000..20a4d437e9 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts @@ -0,0 +1,80 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { + ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot, + RequestPromptChange, +} from '@deepseek-ai/dsh-client-runtime/client' +import { trajectoryNode } from './trajectory-definition-common.ts' +import type { TrajectoryRequestHeaderState } from './trajectory-contract.ts' + +function requestPrompt(match: ConversationMatch): ConversationPromptSnapshot { + if (match.event.type !== 'request/header') { + throw new Error('trajectory-request-header start requires request/header') + } + const header = match.event.data.header + const tools: unknown = header.tools + return { + config: header.config, + system: header.system ?? '', + tools: Array.isArray(tools) ? tools as ConversationPromptSnapshot['tools'] : [], + } +} + +function promptChange( + previous: ConversationPromptSnapshot | undefined, + prompt: ConversationPromptSnapshot, + match: ConversationMatch, +): RequestPromptChange | undefined { + if (match.event.type !== 'request/header') return undefined + if (previous === undefined && match.event.data.reason !== 'initial') return undefined + const systemChanged = previous !== undefined && previous.system !== prompt.system + const toolsChanged = previous !== undefined + && JSON.stringify(previous.tools) !== JSON.stringify(prompt.tools) + if (previous !== undefined && !systemChanged && !toolsChanged) return undefined + return { + seq: match.event.seq, + time: match.event.time, + kind: previous === undefined + ? 'initial' + : systemChanged && toolsChanged + ? 'system-and-tools' + : systemChanged ? 'system' : 'tools', + ...(previous === undefined ? {} : { previous }), + } +} + +const trajectoryRequestHeaderDefinition: ConversationNodeDefinition = { + kind: 'trajectory-request-header', + target: 'trajectory', + match: event => event.type === 'request/header' + ? { id: String(event.seq), role: 'start' } + : null, + start: (_context, match, reader) => { + const prompt = requestPrompt(match) + const previous = reader.previous('trajectory-request-header') + ?.state.prompt + const change = promptChange(previous, prompt, match) + return { + seq: match.event.seq, + time: match.event.time, + prompt, + location: match.location, + ...(change === undefined ? {} : { change }), + } + }, + update: context => context.state, + buildViewNode: context => context.state === undefined + ? null + : trajectoryNode(context, context.state.seq, { + kind: 'request-header', + header: context.state, + }), +} + +/** + * Register Trajectory request-header facts. + * + * @param ctx - Plugin context receiving the Definition. + */ +export function registerTrajectoryRequestHeaderDefinition(ctx: Context): void { + ctx.conversationEvents.register(trajectoryRequestHeaderDefinition) +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-search-index.ts b/packages/client/ui-trajectory/src/client/trajectory-search-index.ts new file mode 100644 index 0000000000..93dddb6856 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-search-index.ts @@ -0,0 +1,133 @@ +/** Incremental full-text index for the trajectory ledger. */ + +import type { TrajectoryTurnModel } from './layout.ts' +import type { TrajectoryCellProps } from './trajectory-record.ts' +import { trajectoryRecordId } from './trajectory-record.ts' +import { trajectoryPreviewText } from './trajectory-preview.ts' + +interface SearchEntry { + readonly sources: readonly string[] + readonly text: string +} + +function searchableJson(value: unknown): string { + if (value === undefined) return '' + try { + return JSON.stringify(value) + } catch { + return '' + } +} + +function sameSources(left: readonly string[], right: readonly string[]): boolean { + return left.length === right.length && left.every((value, index) => value === right[index]) +} + +function markdownPreview(cell: TrajectoryCellProps): string { + if (cell.previewMarkdown === undefined) return '' + const preview = trajectoryPreviewText(cell.previewMarkdown) + if (cell.text === '') return preview + return preview === '' ? cell.text : `${cell.text} · ${preview}` +} + +function resultPreview(cell: TrajectoryCellProps): string { + return cell.resultPreviewMarkdown === undefined + ? cell.result ?? '' + : trajectoryPreviewText(cell.resultPreviewMarkdown) +} + +function recordSources( + turn: number | null, + group: string, + cell: TrajectoryCellProps, +): readonly string[] { + const blocks = [ + ...(cell.sourceBlocks ?? []), + ...(cell.outputBlocks ?? []), + ] + return [ + turn === null ? 'between turns' : `turn ${turn}`, + group, + cell.kind, + cell.kind === 'message' ? 'assistant' : '', + cell.text, + cell.previewMarkdown ?? '', + cell.inputDetail ?? '', + cell.outputDetail ?? '', + cell.thinkingDetail ?? '', + cell.schemaDetail ?? '', + cell.result ?? '', + cell.resultPreviewMarkdown ?? '', + cell.callId ?? '', + ...blocks.flatMap(block => [ + block.type, + block.content, + block.callId ?? '', + block.toolName ?? '', + block.imageAlt ?? '', + ]), + searchableJson(cell.messageSource), + searchableJson(cell.promptDetail), + searchableJson(cell.previousPromptDetail), + ] +} + +/** Session-view-local index that reparses Markdown only when one record's source changes. */ +export class TrajectorySearchIndex { + private readonly entries = new Map() + private layouts: readonly (readonly TrajectoryTurnModel[])[] | undefined + + /** + * Incrementally synchronize one or more current trajectory layout slices. + * @param layouts - Finalized and optional streaming layouts from the same view. + * @returns Whether the indexed layout version changed. + */ + update(layouts: readonly (readonly TrajectoryTurnModel[])[]): boolean { + if (this.layouts === layouts) return false + this.layouts = layouts + const seen = new Set() + for (const turns of layouts) { + for (const turn of turns) { + for (const group of turn.groups) { + for (const cell of group.cells) { + if (cell.requestOnly === true) continue + const id = trajectoryRecordId(cell) + const sources = recordSources(turn.turn, group.title, cell) + const previous = this.entries.get(id) + const entry = previous !== undefined && sameSources(previous.sources, sources) + ? previous + : { + sources, + text: [ + ...sources, + markdownPreview(cell), + resultPreview(cell), + ].join('\n').toLocaleLowerCase(), + } + this.entries.set(id, entry) + seen.add(id) + } + } + } + } + for (const id of this.entries.keys()) { + if (!seen.has(id)) this.entries.delete(id) + } + return true + } + + /** + * Match a query against the latest committed index version. + * @param query - Space-separated case-insensitive search terms. + * @returns Matching stable record identities, or `null` without a query. + */ + search(query: string): ReadonlySet | null { + const terms = query.trim().toLocaleLowerCase().split(/\s+/).filter(Boolean) + if (terms.length === 0) return null + const matches = new Set() + for (const [id, entry] of this.entries) { + if (terms.every(term => entry.text.includes(term))) matches.add(id) + } + return matches + } +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts new file mode 100644 index 0000000000..8ca382bcc5 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -0,0 +1,284 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { + AssistantMessageNode, ConversationNode, ConversationPromptSnapshot, + ConversationViewBuilder, ConversationViewDefinition, RequestView, + ToolCallBlock, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { + TrajectoryConversationViewNode, TrajectoryRequestHeaderState, + TrajectorySnapshot, +} from './trajectory-contract.ts' + +const EMPTY_LIST: readonly never[] = [] +type AssistantRequest = Extract +type ToolSchema = ConversationPromptSnapshot['tools'][number] + +/** Stable empty target used until a Session has assembled Trajectory records. */ +export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = { + eventNodes: EMPTY_LIST, + eventLocations: new Map(), + requests: EMPTY_LIST, + callSchemas: new Map(), + partial: null, + runningCalls: EMPTY_LIST, +} + +function stepKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + +function headerStepKey(header: TrajectoryRequestHeaderState): string | undefined { + const location = header.location + return location.kind === 'step' + ? stepKey(location.turn.turn, location.step.step) + : undefined +} + +function headerFor( + request: AssistantRequest, + headersByStep: ReadonlyMap, + previous: TrajectoryRequestHeaderState | undefined, +): TrajectoryRequestHeaderState | undefined { + return headersByStep.get(stepKey(request.turn, request.step)) + ?? (previous !== undefined && previous.seq < request.startSeq ? previous : undefined) +} + +function applyHeader( + request: AssistantRequest, + header: TrajectoryRequestHeaderState | undefined, + includeChange: boolean, +): AssistantRequest { + return header === undefined + ? request + : { + ...request, + prompt: header.prompt, + requestConfig: header.prompt.config, + ...(includeChange && header.change !== undefined ? { promptChange: header.change } : {}), + } +} + +function withRequestConfig( + node: AssistantMessageNode, + prompt: ConversationPromptSnapshot | undefined, +): AssistantMessageNode { + return prompt === undefined ? node : { ...node, requestConfig: prompt.config } +} + +function captureSchemas( + block: ToolCallBlock, + toolsByName: ReadonlyMap, + output: Map, +): void { + const name = 'kind' in block ? block.call?.name : block.name + const schema = name === undefined ? undefined : toolsByName.get(name) + if (schema !== undefined) output.set(block.callId, schema) + for (const child of block.subCalls) captureSchemas(child, toolsByName, output) +} + +function indexTools(tools: readonly ToolSchema[]): ReadonlyMap { + return new Map(tools.map(tool => [tool.name, tool])) +} + +function interruptCompactions( + requests: RequestView[], + boundaries: readonly { seq: number; time: number }[], +): void { + let nextRequest = 0 + const runningCompactions: number[] = [] + for (const boundary of boundaries) { + while (nextRequest < requests.length) { + const request = requests[nextRequest] + if (request === undefined || request.startSeq >= boundary.seq) break + if (request.purpose === 'compaction' && request.status === 'running') { + runningCompactions.push(nextRequest) + } + nextRequest++ + } + let index = runningCompactions.pop() + while (index !== undefined && requests[index]?.status !== 'running') { + index = runningCompactions.pop() + } + if (index === undefined) continue + const request = requests[index] + if (request?.purpose !== 'compaction') continue + requests[index] = { + ...request, + completedAt: boundary.time, + status: 'error', + error: 'Compaction was interrupted before completion.', + } + } +} + +function applyTurnErrors( + requests: RequestView[], + endings: readonly { turn: number; time: number; error?: string }[], +): void { + const lastAssistantByTurn = new Map() + for (const [index, request] of requests.entries()) { + if (request.purpose === 'assistant') lastAssistantByTurn.set(request.turn, index) + } + for (const ending of endings) { + if (ending.error === undefined) continue + const index = lastAssistantByTurn.get(ending.turn) + if (index === undefined) continue + const request = requests[index] + if (request?.purpose !== 'assistant') continue + requests[index] = { + ...request, + completedAt: request.completedAt ?? ending.time, + status: 'error', + error: ending.error, + } + } +} + +/** Simple keyed adapter retaining the old Trajectory snapshot and stage layout. */ +export class TrajectorySnapshotBuilder implements ConversationViewBuilder< + TrajectoryConversationViewNode, + TrajectorySnapshot +> { + private readonly nodes = new Map() + private readonly positions = new Map() + private contributions: TrajectoryConversationViewNode[] = [] + readonly empty = EMPTY_TRAJECTORY_SNAPSHOT + + replace(input: { + readonly nodes: readonly TrajectoryConversationViewNode[] + }): TrajectorySnapshot { + this.nodes.clear() + for (const node of input.nodes) this.nodes.set(node.key, node) + this.rebuildContributions() + return this.snapshot() + } + + apply(input: { + readonly upserts: readonly TrajectoryConversationViewNode[] + }): TrajectorySnapshot { + let structural = false + for (const node of input.upserts) { + const previous = this.nodes.get(node.key) + this.nodes.set(node.key, node) + if (previous === undefined || previous.anchorSeq !== node.anchorSeq) { + structural = true + continue + } + const position = this.positions.get(node.key) + if (position === undefined) structural = true + else this.contributions[position] = node + } + if (structural) this.rebuildContributions() + return this.snapshot() + } + + private snapshot(): TrajectorySnapshot { + const headersByStep = new Map() + for (const contribution of this.contributions) { + if (contribution.data.kind !== 'request-header') continue + const key = headerStepKey(contribution.data.header) + if (key !== undefined) headersByStep.set(key, contribution.data.header) + } + const finalized: ConversationNode[] = [] + const eventLocations = new Map() + const requests: RequestView[] = [] + const boundaries: { seq: number; time: number }[] = [] + const turnEndings: { turn: number; time: number; error?: string }[] = [] + const callSchemas = new Map() + const consumedPromptChanges = new Set() + let previousHeader: TrajectoryRequestHeaderState | undefined + let previousTools: ReadonlyMap = new Map() + let partial: TrajectorySnapshot['partial'] = null + const runningCalls: TrajectorySnapshot['runningCalls'][number][] = [] + + for (const contribution of this.contributions) { + const data = contribution.data + if (data.kind === 'request-header') { + previousHeader = data.header + previousTools = indexTools(data.header.prompt.tools) + continue + } + if (data.kind === 'node') { + finalized.push(data.node) + eventLocations.set(data.node.seq, contribution.location) + continue + } + if (data.kind === 'assistant') { + const header = data.request === undefined + ? undefined + : headerFor(data.request, headersByStep, previousHeader) + if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.prompt)) + if (data.partial !== null) partial = data.partial + if (data.request !== undefined) { + const includeChange = header?.change !== undefined + && !consumedPromptChanges.has(header.seq) + requests.push(applyHeader(data.request, header, includeChange)) + if (includeChange) consumedPromptChanges.add(header.seq) + } + continue + } + if (data.kind === 'tool') { + if ('kind' in data.root) finalized.push(data.root) + else runningCalls.push(data.root) + if (previousHeader !== undefined && previousHeader.seq < contribution.anchorSeq) { + captureSchemas(data.root, previousTools, callSchemas) + } + continue + } + if (data.kind === 'compaction') { + requests.push(data.request) + continue + } + if (data.kind === 'session-end') { + boundaries.push({ seq: data.seq, time: data.time }) + continue + } + turnEndings.push({ + turn: data.turn, + time: data.time, + ...(data.error === undefined ? {} : { error: data.error }), + }) + } + + requests.sort((left, right) => left.startSeq - right.startSeq) + interruptCompactions(requests, boundaries) + applyTurnErrors(requests, turnEndings) + finalized.sort((left, right) => left.seq - right.seq) + const eventNodes = finalized + return { + eventNodes, + eventLocations, + requests, + callSchemas, + partial, + runningCalls, + } + } + + private rebuildContributions(): void { + this.contributions = [...this.nodes.values()] + .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) + this.positions.clear() + for (const [index, contribution] of this.contributions.entries()) { + this.positions.set(contribution.key, index) + } + } +} + +/** Trajectory target factory preserving the existing stage-oriented view model. */ +export const trajectoryViewDefinition: ConversationViewDefinition< + TrajectoryConversationViewNode, + TrajectorySnapshot +> = { + target: 'trajectory', + create: () => new TrajectorySnapshotBuilder(), +} + +/** + * Register the stage-oriented Trajectory target builder. + * + * @param ctx - Plugin context receiving the view Definition. + */ +export function registerTrajectoryConversationView(ctx: Context): void { + ctx.conversationViews.register(trajectoryViewDefinition) +} diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts new file mode 100644 index 0000000000..7e069dd912 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -0,0 +1,273 @@ +import type { Context } from '@deepseek-ai/cordis' +import type { + ConversationMatch, ConversationNodeContext, ConversationNodeDefinition, + RunningToolCall, ToolCallBlock, ToolResultNode, +} from '@deepseek-ai/dsh-client-runtime/client' +import type {} from '@deepseek-ai/dsh-tools/types' +import { trajectoryNode } from './trajectory-definition-common.ts' + +/* jscpd:ignore-start -- Target-owned Definitions intentionally keep their event + * state machines independent; see ../../../../../.agents/notes/implemented/ + * architecture/2026-08-09-client-conversation-node-assembly.md. */ +const MAX_DEPTH = 256 + +interface ToolState { + readonly rootId: string + readonly calls: ReadonlyMap + readonly children: ReadonlyMap + readonly parents: ReadonlyMap +} + +interface DispatchData { + readonly parentCallId: string + readonly subCallId: string + readonly name: string + readonly arguments: unknown + readonly isError?: boolean + readonly content?: ToolResultNode['content'] +} + +function rootCall(match: ConversationMatch): RunningToolCall { + if (match.event.type !== 'tool/call') { + throw new Error('trajectory-tool-call start requires tool/call') + } + return { + callId: String(match.event.data.callId), + name: match.event.data.name, + argsRaw: match.event.data.arguments, + turn: match.event.data.turn, + step: match.event.data.step, + time: match.event.time, + callView: match.view?.for === 'call' ? match.view.view : null, + subCalls: [], + } +} + +function rootResult( + match: ConversationMatch, + previous?: RunningToolCall, +): ToolResultNode | undefined { + if (match.event.type !== 'tool/result') return undefined + const result = match.event.data.message.content[0] + return { + kind: 'tool-result', + seq: match.event.seq, + time: match.event.time, + callId: String(match.event.data.message.source.callId), + call: previous === undefined ? null : { name: previous.name, argsRaw: previous.argsRaw }, + callTime: previous?.time ?? null, + content: result.content, + isError: result.isError === true, + ...(match.event.data.error === undefined ? {} : { error: match.event.data.error }), + meta: match.event.data.meta, + callView: previous?.callView ?? null, + resultView: match.view?.for === 'result' ? match.view.view : null, + subCalls: [], + } +} + +function locationTurn(match: ConversationMatch): number { + return match.location.kind === 'step' || match.location.kind === 'turn' + ? match.location.turn.turn + : 0 +} + +function locationStep(match: ConversationMatch): number { + return match.location.kind === 'step' ? match.location.step.step : 0 +} + +function childCall(match: ConversationMatch, data: DispatchData): RunningToolCall { + return { + callId: data.subCallId, + name: data.name, + argsRaw: JSON.stringify(data.arguments), + turn: locationTurn(match), + step: locationStep(match), + time: match.event.time, + callView: null, + subCalls: [], + } +} + +function childResult( + match: ConversationMatch, + data: DispatchData, + previous?: ToolCallBlock, +): ToolResultNode { + return { + kind: 'tool-result', + seq: match.event.seq, + time: match.event.time, + callId: data.subCallId, + call: { name: data.name, argsRaw: JSON.stringify(data.arguments) }, + callTime: previous === undefined || 'kind' in previous ? null : previous.time, + content: data.content ?? [], + isError: data.isError === true, + callView: null, + resultView: null, + subCalls: [], + } +} + +function acceptsEdge(state: ToolState, parent: string, child: string): boolean { + if (parent === child || state.parents.has(child)) return false + let cursor: string | undefined = parent + let parentDepth = 0 + const ancestors = new Set() + while (cursor !== undefined) { + if (cursor === child || ancestors.has(cursor)) return false + ancestors.add(cursor) + parentDepth++ + cursor = state.parents.get(cursor) + } + const pending = [{ callId: child, depth: 1 }] + const descendants = new Set() + let subtreeDepth = 0 + for (const candidate of pending) { + if (descendants.has(candidate.callId)) return false + descendants.add(candidate.callId) + subtreeDepth = Math.max(subtreeDepth, candidate.depth) + for (const nested of state.children.get(candidate.callId) ?? []) { + pending.push({ callId: nested, depth: candidate.depth + 1 }) + } + } + return parentDepth + subtreeDepth <= MAX_DEPTH +} + +function updateDispatch(state: ToolState, match: ConversationMatch): ToolState { + const event = match.event + if (event.type !== 'tool/code-dispatch-start' && event.type !== 'tool/code-dispatch') return state + const data = event.data + const parentId = String(data.parentCallId) + const childId = String(data.subCallId) + const siblings = state.children.get(parentId) ?? [] + const index = siblings.indexOf(childId) + if (index < 0 && !acceptsEdge(state, parentId, childId)) return state + if (event.type === 'tool/code-dispatch-start' && index >= 0) return state + + const calls = new Map(state.calls) + calls.set(childId, event.type === 'tool/code-dispatch-start' + ? childCall(match, data) + : childResult(match, data, calls.get(childId))) + if (index >= 0) return { ...state, calls } + const children = new Map(state.children) + children.set(parentId, [...siblings, childId]) + const parents = new Map(state.parents) + parents.set(childId, parentId) + return { ...state, calls, children, parents } +} + +function interruption( + context: ConversationNodeContext, +): { seq: number; time: number } | undefined { + const location = context.start?.location + if (location?.kind === 'step' && location.step.status === 'closed') return location.step.end + if ((location?.kind === 'step' || location?.kind === 'turn') + && location.turn.status === 'closed') return location.turn.end + return undefined +} + +function projectCall( + state: ToolState, + callId: string, + interruptedAt: { seq: number; time: number } | undefined, + visited = new Set(), + depth = 1, +): ToolCallBlock | undefined { + const block = state.calls.get(callId) + if (block === undefined) return undefined + if (visited.has(callId) || depth > MAX_DEPTH) return { ...block, subCalls: [] } + const nextVisited = new Set(visited) + nextVisited.add(callId) + const subCalls = (state.children.get(callId) ?? []) + .flatMap((childId) => { + const child = projectCall(state, childId, interruptedAt, nextVisited, depth + 1) + return child === undefined ? [] : [child] + }) + if ('kind' in block || interruptedAt === undefined) return { ...block, subCalls } + return { + kind: 'tool-result', + seq: interruptedAt.seq - 0.8, + time: interruptedAt.time, + callId: block.callId, + call: { name: block.name, argsRaw: block.argsRaw }, + callTime: block.time, + content: [], + isError: true, + error: { name: 'Interrupted', code: 'interrupted' }, + callView: block.callView, + resultView: null, + subCalls, + } +} + +function fallbackState(context: ConversationNodeContext): ToolState | undefined { + const resultMatch = context.matches.find(match => match.event.type === 'tool/result') + const root = resultMatch === undefined ? undefined : rootResult(resultMatch) + if (root === undefined) return undefined + let state: ToolState = { + rootId: root.callId, + calls: new Map([[root.callId, root]]), + children: new Map(), + parents: new Map(), + } + for (const match of context.matches) state = updateDispatch(state, match) + return state +} + +/** Trajectory-owned root Tool lifecycle with nested Code Dispatch calls. */ +const trajectoryToolDefinition: ConversationNodeDefinition = { + kind: 'trajectory-tool-call', + target: 'trajectory', + match: (event) => { + if (event.type === 'tool/call') return { id: String(event.data.callId), role: 'start' } + if (event.type === 'tool/result') { + return { id: String(event.data.message.source.callId), role: 'update' } + } + if (event.type === 'tool/code-dispatch-start' || event.type === 'tool/code-dispatch') { + const rootCallId: unknown = event.data.rootCallId + return typeof rootCallId === 'string' && rootCallId !== '' + ? { id: rootCallId, role: 'update' } + : null + } + return null + }, + start: (_context, match) => { + const root = rootCall(match) + return { + rootId: root.callId, + calls: new Map([[root.callId, root]]), + children: new Map(), + parents: new Map(), + } + }, + update: (context, match) => { + if (match.event.type !== 'tool/result') return updateDispatch(context.state, match) + const previous = context.state.calls.get(context.state.rootId) + const running = previous !== undefined && !('kind' in previous) ? previous : undefined + const result = rootResult(match, running) + if (result === undefined) return context.state + const calls = new Map(context.state.calls) + calls.set(context.state.rootId, result) + return { ...context.state, calls } + }, + buildViewNode: (context) => { + const state = context.state ?? fallbackState(context) + if (state === undefined) return null + const root = projectCall(state, state.rootId, interruption(context)) + if (root === undefined) return null + const anchorSeq = context.start?.event.seq + ?? ('kind' in root ? root.seq : context.matches[0]?.event.seq ?? 0) + return trajectoryNode(context, anchorSeq, { kind: 'tool', root }) + }, +} +/* jscpd:ignore-end */ + +/** + * Register the Trajectory Tool lifecycle. + * + * @param ctx - Plugin context receiving the Definition. + */ +export function registerTrajectoryToolDefinition(ctx: Context): void { + ctx.conversationEvents.register(trajectoryToolDefinition) +} diff --git a/packages/client/ui-trajectory/src/client/views.module.css b/packages/client/ui-trajectory/src/client/views.module.css index 687f4a4657..842486ea93 100644 --- a/packages/client/ui-trajectory/src/client/views.module.css +++ b/packages/client/ui-trajectory/src/client/views.module.css @@ -13,6 +13,18 @@ background: var(--dsw-alias-bg-layer-1); } +.exportError { + box-sizing: border-box; + flex: none; + width: 100%; + padding: 4px 10px; + border-bottom: 1px solid var(--dsw-alias-border-l2); + color: var(--dsw-alias-label-danger, var(--dsw-alias-label-primary)); + background: var(--dsw-alias-bg-layer-2); + font: var(--dsw-font-xxs-12); + overflow-wrap: anywhere; +} + .ledger { position: relative; z-index: 0; diff --git a/packages/client/ui-trajectory/src/invariant.ts b/packages/client/ui-trajectory/src/invariant.ts index 11e56bb058..7a43d872cc 100644 --- a/packages/client/ui-trajectory/src/invariant.ts +++ b/packages/client/ui-trajectory/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-trajectory' diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 579a97337a..b7a3d987e0 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -8,9 +8,11 @@ */ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it } from 'vitest' -import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' +import { + ConversationEventRegistry, ConversationViewRegistry, SlotsService, +} from '@deepseek-ai/dsh-client-runtime/client' const PLUGIN_ID = '@deepseek-ai/dsh-client-ui-trajectory' @@ -61,26 +63,41 @@ describe('tsdown client artifact', () => { const { handoff, surface } = await loadArtifact() expect(handoff.id).toBe(PLUGIN_ID) expect(surface.apply).toBeTypeOf('function') - expect(surface.inject).toEqual(['slots', 'sessionHistory']) + expect(surface.inject).toEqual([ + 'slots', 'conversationEvents', 'conversationViews', 'sessions', 'locale', + ]) }) it.skipIf(code === undefined)('mounted as an object plugin, apply registers the view tab on the real ring', async () => { const { surface } = await loadArtifact() const ctx = new Context() const slots = new SlotsService(ctx) + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() // The conversation entry's role: the ring must be declared before riders land. slots.register({ name: 'root', children: { 'conversation.view': { kind: 'list', scope: 'session' } }, }, (_p: { renderSlot?: unknown }) => null) - // The plugin reads sessionHistory for its per-session history source; - // slot availability is tracked by slots.inject. - ctx.provide('sessionHistory', {}) + // Paging is session-owned; this registration-only probe never renders the + // entry, so the binding stays deliberately empty. The locale plugin backs + // the locale-aware view tab label (its settings scope needs a connection + // handle). + ctx.provide('sessions', { binding: () => undefined }) + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + const locale = await import('@deepseek-ai/dsh-client-locale/client') + ctx.plugin({ inject: [...locale.inject], apply: locale.apply }) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() + const events = ctx.get('conversationEvents') as ConversationEventRegistry + const views = ctx.get('conversationViews') as ConversationViewRegistry expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory']) + expect(events.entries().length).toBeGreaterThan(0) + expect(views.entries()).toHaveLength(1) await fiber.dispose() expect(slots.entries('conversation.view')).toHaveLength(0) + expect(events.entries()).toEqual([]) + expect(views.entries()).toEqual([]) }) it.skipIf(code === undefined)('injects plugin-tagged module CSS during factory execution', async () => { diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts deleted file mode 100644 index e608b9fd68..0000000000 --- a/packages/client/ui-trajectory/tests/context-branches.spec.ts +++ /dev/null @@ -1,102 +0,0 @@ -import { describe, expect, it } from 'vitest' -import type { - ConversationContext, ConversationNode, RequestView, -} from '@deepseek-ai/dsh-client-runtime/client' -import { - deriveTrajectoryContextBranches, - trajectoryBranchContainsRequest, -} from '../src/client/context-branches.ts' - -const checkpoint = { - kind: 'context', - seq: 100, - time: 100, - content: [], - source: { kind: 'plugin', plugin: 'compact' }, - provenance: { role: 'inject', label: 'compact' }, - form: null, -} as ConversationNode - -const abandoned = { - kind: 'assistant', - seq: 20, - time: 20, - turn: 1, - step: 1, - blocks: [{ kind: 'text', text: 'abandoned' }], -} as ConversationNode - -const current = { - kind: 'user', - seq: 110, - time: 110, - content: [{ type: 'text', text: 'rewound' }], - source: { kind: 'plugin', plugin: 'rewind' }, -} as ConversationNode - -function request( - purpose: RequestView['purpose'], - startSeq: number, - resultSeq?: number, - replacementSeq?: number, -): RequestView { - const base = { - startSeq, - startedAt: startSeq, - completedAt: startSeq + 1, - status: 'complete' as const, - ...(resultSeq === undefined ? {} : { resultSeq }), - } - return purpose === 'assistant' - ? { ...base, purpose, turn: 1, step: 1 } - : { - ...base, - purpose, - turn: 1, - step: 0, - ...(replacementSeq === undefined ? {} : { replacementSeq }), - } -} - -describe('trajectory context branches', () => { - it('inherits nodes and requests by retained surface position rather than seq cutoff', () => { - const contexts: ConversationContext[] = [ - { id: 0, nodes: [checkpoint, abandoned] }, - { - id: 1, - parentId: 0, - origin: 'rewind', - originSeq: 110, - nodes: [checkpoint, current], - }, - ] - const branches = deriveTrajectoryContextBranches(contexts) - const successor = branches[1]! - - expect(successor.key).toBe('rewind:110') - expect(successor.nodes.map(node => node.seq)).toEqual([110]) - expect(trajectoryBranchContainsRequest( - successor, - request('assistant', 10, 20), - )).toBe(false) - expect(trajectoryBranchContainsRequest( - successor, - request('compaction', 90, 95, 100), - )).toBe(true) - expect(trajectoryBranchContainsRequest( - successor, - request('assistant', 111), - )).toBe(true) - }) - - it('keeps branch identity when prepended generations shift local ids', () => { - const branch = (id: number) => deriveTrajectoryContextBranches([{ - id, - origin: 'rewind', - originSeq: 110, - nodes: [current], - }])[0] - - expect(branch(1)?.key).toBe(branch(9)?.key) - }) -}) diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts new file mode 100644 index 0000000000..9999169896 --- /dev/null +++ b/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts @@ -0,0 +1,287 @@ +import type { Context } from '@deepseek-ai/cordis' +import { describe, expect, it } from 'vitest' +import type { + ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition, +} from '@deepseek-ai/dsh-client-runtime/client' +import { ConversationNodeAssembler } from '@deepseek-ai/dsh-client-runtime/client' +import { registerTrajectoryAssistantDefinition } from '../src/client/trajectory-assistant-definition.ts' +import { registerTrajectoryCompactionDefinitions } from '../src/client/trajectory-compaction-definition.ts' +import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts' +import { registerTrajectoryMessageDefinitions } from '../src/client/trajectory-message-definitions.ts' +import { registerTrajectoryRequestHeaderDefinition } from '../src/client/trajectory-request-header-definition.ts' +import { trajectoryViewDefinition } from '../src/client/trajectory-snapshot-builder.ts' +import { registerTrajectoryToolDefinition } from '../src/client/trajectory-tool-definition.ts' + +const DEFINITIONS: ConversationNodeDefinition[] = [] +const registrationContext = { + conversationEvents: { + register: (definition: ConversationNodeDefinition) => { + DEFINITIONS.push(definition) + return () => {} + }, + }, +} as unknown as Context + +registerTrajectoryMessageDefinitions(registrationContext) +registerTrajectoryRequestHeaderDefinition(registrationContext) +registerTrajectoryAssistantDefinition(registrationContext) +registerTrajectoryToolDefinition(registrationContext) +registerTrajectoryCompactionDefinitions(registrationContext) + +class TestEventDefinitions { + entries(): readonly ConversationNodeDefinition[] { + return DEFINITIONS + } + + fallbackEntry(): undefined { + return undefined + } +} + +class TestViewDefinitions { + entries(): readonly ConversationViewDefinition[] { + return [trajectoryViewDefinition] + } +} + +function at( + seq: number, + type: string, + data: unknown, + extra: Record = {}, +): ConversationEventInput { + return { + event: { + seq, + time: 1_700_000_000_000 + seq, + type, + data, + ...extra, + } as unknown as ConversationEventInput['event'], + view: undefined, + } +} + +function assembler(events: readonly ConversationEventInput[]): ConversationNodeAssembler { + const value = new ConversationNodeAssembler( + new TestEventDefinitions(), + new TestViewDefinitions(), + ) + value.replaceWindow(events, false) + value.flush() + return value +} + +function snapshot(value: ConversationNodeAssembler): TrajectorySnapshot { + const current = value.snapshot('trajectory') as TrajectorySnapshot | undefined + if (current === undefined) throw new Error('trajectory view was not registered') + return current +} + +function assistantMessage(id: string, text: string) { + return { + id, + role: 'assistant', + content: [{ type: 'text', text }], + source: { kind: 'model', provider: 'test', model: 'test' }, + } +} + +describe('Trajectory conversation Definitions', () => { + it('assembles streaming usage, preserves retry facts, and materializes interruption', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'first attempt' }, + }), + at(4, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'usage', usage: { inputTokens: 10, outputTokens: 3 } }, + }), + ]) + + expect(snapshot(value).partial?.blocks).toEqual([{ kind: 'text', text: 'first attempt' }]) + expect(snapshot(value).requests).toMatchObject([{ + purpose: 'assistant', + status: 'running', + usage: { inputTokens: 10, outputTokens: 3 }, + }]) + + value.append(at(5, 'llm/retry', { + retryId: 'retry-1', + turn: 1, + step: 1, + provider: 'test', + mode: 'normal', + policyKey: 'test-normal', + retry: 1, + maxRetries: 2, + delayMs: 25, + failure: { code: 'TRANSPORT', message: 'temporary failure' }, + })) + value.append(at(6, 'assistant/chunk', { + turn: 1, + step: 1, + chunk: { type: 'text-delta', index: 0, text: 'second attempt' }, + })) + value.append(at(7, 'step/end', { turn: 1, step: 1 })) + value.flush() + + const settled = snapshot(value) + expect(settled.partial).toBeNull() + expect(settled.eventNodes).toMatchObject([{ + kind: 'assistant', + seq: 6.1, + interrupted: true, + blocks: [{ kind: 'text', text: 'second attempt' }], + }]) + expect(settled.requests).toMatchObject([{ + purpose: 'assistant', + status: 'error', + retry: 1, + maxRetries: 2, + retryDelayMs: 25, + usage: { inputTokens: 10, outputTokens: 3 }, + }]) + }) + + it('keeps parallel interrupted roots and nests Code Dispatch results', () => { + const current = snapshot(assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'step/start', { turn: 1, step: 1 }), + at(3, 'tool/call', { + turn: 1, step: 1, callId: 'root-a', name: 'code', arguments: '{}', + }), + at(4, 'tool/call', { + turn: 1, step: 1, callId: 'root-b', name: 'parallel', arguments: '{}', + }), + at(5, 'tool/code-dispatch-start', { + rootCallId: 'root-a', + parentCallId: 'root-a', + subCallId: 'child', + name: 'read', + arguments: { path: 'README.md' }, + }), + at(6, 'tool/code-dispatch', { + rootCallId: 'root-a', + parentCallId: 'root-a', + subCallId: 'child', + name: 'read', + arguments: { path: 'README.md' }, + content: [{ type: 'text', text: 'contents' }], + }), + at(7, 'step/end', { turn: 1, step: 1 }), + ])) + + const tools = current.eventNodes.filter(node => node.kind === 'tool-result') + expect(tools.map(node => node.callId).sort()).toEqual(['root-a', 'root-b']) + expect(tools.find(node => node.callId === 'root-a')?.subCalls).toMatchObject([{ + kind: 'tool-result', + callId: 'child', + call: { name: 'read' }, + }]) + }) + + it('assembles compaction lifecycle, checkpoint replacement, and orphan interruption', () => { + const current = snapshot(assembler([ + at(1, 'compact/start', { compactionId: 'complete', turn: null }), + at(2, 'compact/summary', { + compactionId: 'complete', + turn: null, + summary: 'summary', + provider: 'test', + model: 'test', + maxTokens: 100, + usage: { inputTokens: 20, outputTokens: 5 }, + }), + at(3, 'user/message', { + id: 'checkpoint', + role: 'user', + content: [{ type: 'text', text: 'summary checkpoint' }], + source: { kind: 'plugin', plugin: 'compact', compactionId: 'complete' }, + }), + at(4, 'compact/end', { compactionId: 'complete', turn: null }), + at(5, 'compact/start', { compactionId: 'orphan', turn: null }), + at(6, 'session/end-seed', {}), + ])) + + expect(current.requests).toMatchObject([ + { + purpose: 'compaction', + startSeq: 1, + status: 'complete', + resultSeq: 2, + replacementSeq: 3, + summary: 'summary', + }, + { + purpose: 'compaction', + startSeq: 5, + status: 'error', + completedAt: 1_700_000_000_006, + }, + ]) + }) + + it('classifies claimed inbox input as steering and consumes one inherited prompt change', () => { + const value = assembler([ + at(1, 'turn/start', { turn: 1 }), + at(2, 'request/header', { + reason: 'initial', + header: { + config: { provider: 'test', model: 'test' }, + system: 'system prompt', + tools: [], + }, + }), + at(3, 'step/start', { turn: 1, step: 1 }), + at(4, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('assistant-1', 'first'), + }), + at(5, 'step/end', { turn: 1, step: 1 }), + at(6, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 0, inserted: [{ id: 'm1' }], + }), + at(7, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + }), + at(8, 'step/start', { turn: 1, step: 2 }), + ]) + value.append(at(9, 'user/message', { + id: 'm1', + role: 'user', + content: [{ type: 'text', text: 'steer here' }], + source: { kind: 'user' }, + })) + value.flush() + + const steering = snapshot(value) + expect(steering.eventNodes.find(node => node.seq === 9)?.kind).toBe('steering') + expect(steering.eventLocations.get(9)).toMatchObject({ + kind: 'step', + turn: { turn: 1 }, + step: { step: 2 }, + }) + + value.append(at(10, 'assistant/message', { + turn: 1, + step: 2, + message: assistantMessage('assistant-2', 'second'), + })) + value.flush() + const current = snapshot(value) + + expect(current.requests.map(request => request.purpose === 'assistant' + ? request.prompt?.system + : undefined)).toEqual(['system prompt', 'system prompt']) + expect(current.requests.map(request => request.purpose === 'assistant' + ? request.promptChange?.kind + : undefined)).toEqual(['initial', undefined]) + }) +}) diff --git a/packages/client/ui-trajectory/tests/export-log.spec.ts b/packages/client/ui-trajectory/tests/export-log.spec.ts new file mode 100644 index 0000000000..ba7f739573 --- /dev/null +++ b/packages/client/ui-trajectory/tests/export-log.spec.ts @@ -0,0 +1,24 @@ +// @vitest-environment node +/** + * Session-log export filename derivation. The archive itself is produced and + * streamed by the host (GET /api/session.export); this package only derives + * the download filename and triggers the browser save. + */ + +import { describe, expect, it } from 'vitest' +import { sessionLogZipFilename } from '../src/client/export-log.ts' + +describe('sessionLogZipFilename', () => { + it('keeps safe session ids verbatim', () => { + expect(sessionLogZipFilename('session-abc_1-2')).toBe('dsh-session-session-abc_1-2.zip') + }) + + it('neutralizes unsafe id characters that could shape the filename', () => { + expect(sessionLogZipFilename('../evil')).toBe('dsh-session-___evil.zip') + expect(sessionLogZipFilename('a/b')).toBe('dsh-session-a_b.zip') + }) + + it('strips dots so a dot-only id cannot shape a dot segment', () => { + expect(sessionLogZipFilename('..')).toBe('dsh-session-__.zip') + }) +}) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index 4c49b07907..ec8924505f 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.tsx @@ -6,7 +6,7 @@ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, render, screen } from '@testing-library/react' import type { - ConversationSnapshot, RequestView, + ConversationLocation, ConversationSnapshot, RequestView, } from '@deepseek-ai/dsh-client-runtime/client' import { TrajectoryGroupHeader } from '../src/client/TrajectoryGroupHeader.tsx' import { TrajectoryTurn } from '../src/client/TrajectoryTurn.tsx' @@ -84,7 +84,10 @@ describe('deriveTrajectoryLayout', () => { input: 10, output: 20, think: 5, timeSeconds: 5, }) const tool = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'tool') - expect(tool?.text).toBe('bash · {"command":"ls"}') + expect(tool).toMatchObject({ + text: 'bash', + previewMarkdown: '{"command":"ls"}', + }) expect(tool?.timeSeconds).toBe(1.3) }) @@ -99,7 +102,10 @@ describe('deriveTrajectoryLayout', () => { }) expect(turns[0]?.groups.map(g => g.title)).toEqual(['Step 2']) expect(turns[0]?.groups[0]?.cells[0]).toMatchObject({ - kind: 'tool', text: 'bash · {"command":"pwd"}', timeSeconds: null, + kind: 'tool', + text: 'bash', + previewMarkdown: '{"command":"pwd"}', + timeSeconds: null, }) }) @@ -132,7 +138,8 @@ describe('deriveTrajectoryLayout', () => { expect(streamed[1]?.groups[0]?.cells).toMatchObject([{ index: 2, kind: 'message', - text: 'streaming', + text: '', + previewMarkdown: 'streaming', timeSeconds: null, }]) expect(streamed[1]?.groups[0]?.cells[0]?.requestOnly).toBeUndefined() @@ -222,8 +229,120 @@ describe('deriveTrajectoryLayout', () => { ] as unknown as ConversationSnapshot['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) expect(turns.map(t => t.turn)).toEqual([1, 2]) - expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['first', 'ok1']) - expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.text))).toEqual(['second', 'ok2']) + expect(turns[0]?.groups.flatMap(g => g.cells.map(c => c.previewMarkdown))).toEqual([ + 'first', + 'ok1', + ]) + expect(turns[1]?.groups.flatMap(g => g.cells.map(c => c.previewMarkdown))).toEqual([ + 'second', + 'ok2', + ]) + }) + + it('places steering in its resolved step instead of the turn-opening Message group', () => { + const nodes = [ + { kind: 'user', seq: 1, time: 1_000, content: [{ type: 'text', text: 'start' }], source: null }, + { + kind: 'assistant', seq: 2, time: 2_000, turn: 1, step: 1, + blocks: [{ kind: 'text', text: 'first step' }], + }, + { + kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000, + content: [{ type: 'text', text: 'change direction' }], source: null, + }, + { + kind: 'assistant', seq: 4, time: 4_000, turn: 1, step: 2, + blocks: [{ kind: 'text', text: 'second step' }], + }, + ] as unknown as ConversationSnapshot['nodes'] + const data = { get: () => undefined } + const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data } + const turn = { + turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data, + } + const eventLocations = new Map([[ + 3, + { kind: 'step', turn, step }, + ]]) + + const turns = deriveTrajectoryLayout({ + nodes, + eventLocations, + partial: null, + runningCalls: [], + }) + + expect(turns).toHaveLength(1) + expect(turns[0]?.groups.map(group => group.title)).toEqual([ + 'Message', 'Step 1', 'Step 2', + ]) + expect(turns[0]?.groups[2]?.cells).toMatchObject([ + { kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 }, + { kind: 'message', previewMarkdown: 'second step', sourceSeq: 4 }, + ]) + }) + + it('keeps a running request boundary after steering input', () => { + const nodes = [{ + kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000, + content: [{ type: 'text', text: 'change direction' }], source: null, + }] as unknown as ConversationSnapshot['nodes'] + const data = { get: () => undefined } + const step = { turn: 1, step: 2, start: undefined, end: undefined, status: 'open' as const, data } + const turn = { + turn: 1, start: undefined, end: undefined, status: 'open' as const, steps: [step], data, + } + const eventLocations = new Map([[ + 3, + { kind: 'step', turn, step }, + ]]) + + const turns = deriveTrajectoryLayout({ + nodes, + eventLocations, + partial: null, + runningCalls: [], + requests: [{ + purpose: 'assistant', + startSeq: 2, + turn: 1, + step: 2, + startedAt: 2_000, + completedAt: null, + status: 'running', + }], + }) + + expect(turns[0]?.groups[0]?.cells).toMatchObject([ + { kind: 'user', previewMarkdown: 'change direction', sourceSeq: 3 }, + { kind: 'message', requestOnly: true, sourceSeq: 2 }, + ]) + }) + + it('uses the following assistant step while a historical window lacks steering Location', () => { + const nodes = [ + { + kind: 'steering', messageId: 'steer-1', seq: 3, time: 3_000, + content: [{ type: 'text', text: 'change direction' }], source: null, + }, + { + kind: 'assistant', seq: 4, time: 4_000, turn: 2, step: 3, + blocks: [{ kind: 'text', text: 'continued' }], + }, + ] as unknown as ConversationSnapshot['nodes'] + + const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) + + expect(turns[0]).toMatchObject({ + turn: 2, + groups: [{ + title: 'Step 3', + cells: [ + { kind: 'user', previewMarkdown: 'change direction' }, + { kind: 'message', previewMarkdown: 'continued' }, + ], + }], + }) }) it('places standalone compaction chronologically in its own between-turn section', () => { @@ -263,7 +382,8 @@ describe('deriveTrajectoryLayout', () => { cells: [{ kind: 'compacted', sourceSeq: 3, - text: 'standalone summary', + text: '', + previewMarkdown: 'standalone summary', }], }]) }) @@ -279,7 +399,7 @@ describe('deriveTrajectoryLayout', () => { const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const message = turns[0]?.groups.flatMap(g => g.cells).find(c => c.kind === 'message') expect(message).toMatchObject({ - text: '…', input: 11, output: 22, think: 3, + text: '', previewMarkdown: '…', input: 11, output: 22, think: 3, }) }) @@ -296,9 +416,8 @@ describe('deriveTrajectoryLayout', () => { const message = turns[0]?.groups.flatMap(group => group.cells) .find(cell => cell.kind === 'message') - expect(message?.text.startsWith('Investigation NAVIGATION_OK file_path')).toBe(true) - expect(message?.text.endsWith('…')).toBe(true) - expect(message?.text.length).toBeLessThanOrEqual(513) + expect(message?.text).toBe('') + expect(message?.previewMarkdown).toBe(thinking) expect(message?.thinkingDetail).toBe(thinking) }) @@ -331,7 +450,7 @@ describe('deriveTrajectoryLayout', () => { ] as unknown as ConversationSnapshot['nodes'] const turns = deriveTrajectoryLayout({ nodes, partial: null, runningCalls: [] }) const cells = turns[0]?.groups.flatMap(g => g.cells) ?? [] - const message = cells.find(c => c.kind === 'message' && c.text === 'done') + const message = cells.find(c => c.kind === 'message' && c.previewMarkdown === 'done') // From the compaction marker at 9.5s, not from context at 9s or the earlier surfaces. expect(message?.timeSeconds).toBe(0.5) // Context remains inspectable in trajectory; the Chat marker is not duplicated. @@ -394,7 +513,9 @@ describe('run_code sub-dispatch cells', () => { expect(cells[0]?.text).toBe('Tool call only') // Sequential indexes across the interleave; durations from the pair times. expect(cells.map(c => c.index)).toEqual([1, 2, 3, 4]) - expect(cells[2]).toMatchObject({ text: 'bash · {"x":1}', timeSeconds: 1 }) + expect(cells[2]).toMatchObject({ + text: 'bash', previewMarkdown: '{"x":1}', timeSeconds: 1, + }) expect(cells[3]).toMatchObject({ timeSeconds: 0.5 }) }) @@ -405,7 +526,9 @@ describe('run_code sub-dispatch cells', () => { } const turns = deriveTrajectoryLayout({ nodes: withSubCalls([running]), partial: null, runningCalls: [] }) const sub = turns[0]!.groups.flatMap(g => g.cells).find(c => c.kind === 'subtool') - expect(sub).toMatchObject({ text: 'grep · {"pattern":"x"}', timeSeconds: null }) + expect(sub).toMatchObject({ + text: 'grep', previewMarkdown: '{"pattern":"x"}', timeSeconds: null, + }) }) it('recursively flattens nested child calls immediately after their parent', () => { diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts new file mode 100644 index 0000000000..c0058b75c6 --- /dev/null +++ b/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts @@ -0,0 +1,237 @@ +import { describe, expect, it } from 'vitest' +import type { RequestView } from '@deepseek-ai/dsh-client-runtime/client' +import type { + TrajectoryContribution, TrajectoryConversationViewNode, TrajectoryRequestHeaderState, +} from '../src/client/trajectory-contract.ts' +import { TrajectorySnapshotBuilder } from '../src/client/trajectory-snapshot-builder.ts' + +function assistantRequest(startSeq: number, step: number): Extract { + return { + purpose: 'assistant', + startSeq, + turn: 1, + step, + startedAt: startSeq, + completedAt: startSeq + 1, + status: 'complete', + } +} + +function contribution( + key: string, + anchorSeq: number, + data: TrajectoryContribution, +): TrajectoryConversationViewNode { + return { + key, kind: key, id: key, target: 'trajectory', anchorSeq, + location: { kind: 'session' }, + data, + } +} + +function stepLocation(turn: number, step: number): TrajectoryRequestHeaderState['location'] { + const data = { get: () => undefined } + const stepLocation = { + turn, + step, + start: undefined, + end: undefined, + status: 'unknown' as const, + data, + } + const turnLocation = { + turn, + start: undefined, + end: undefined, + status: 'unknown' as const, + steps: [stepLocation], + data, + } + return { kind: 'step', turn: turnLocation, step: stepLocation } +} + +function compactionRequest(startSeq: number): Extract { + return { + purpose: 'compaction', + startSeq, + turn: null, + step: 0, + startedAt: startSeq, + completedAt: null, + status: 'running', + } +} + +describe('TrajectorySnapshotBuilder', () => { + it('inherits one request header across requests without repeating its prompt change', () => { + const prompt = { + config: { provider: 'test', model: 'test' }, + system: 'one initial prompt', + tools: [], + } + const nodes: TrajectoryConversationViewNode[] = [ + { + key: 'header', + kind: 'trajectory-request-header', + id: '2', + target: 'trajectory', + anchorSeq: 2, + location: { kind: 'session' }, + data: { + kind: 'request-header', + header: { + seq: 2, + time: 2, + prompt, + change: { seq: 2, time: 2, kind: 'initial' }, + location: { kind: 'session' }, + }, + }, + }, + ...[assistantRequest(3, 1), assistantRequest(5, 2)].map(request => ({ + key: `assistant:${request.step}`, + kind: 'trajectory-assistant-step', + id: `1:${request.step}`, + target: 'trajectory' as const, + anchorSeq: request.startSeq, + location: { kind: 'session' as const }, + data: { kind: 'assistant' as const, partial: null, request }, + })), + ] + + const snapshot = new TrajectorySnapshotBuilder().replace({ nodes }) + + expect(snapshot.requests.map(request => request.purpose === 'assistant' + ? request.prompt?.system + : undefined)).toEqual(['one initial prompt', 'one initial prompt']) + expect(snapshot.requests.map(request => request.purpose === 'assistant' + ? request.promptChange?.kind + : undefined)).toEqual(['initial', undefined]) + }) + + it('indexes exact step headers and the active tool schema without backward scans', () => { + const basePrompt = { + config: { provider: 'test', model: 'base' }, + system: 'base prompt', + tools: [{ name: 'read', description: 'Read', parameters: { type: 'object' } }], + } + const exactPrompt = { + config: { provider: 'test', model: 'exact' }, + system: 'exact prompt', + tools: [{ name: 'edit', description: 'Edit', parameters: { type: 'object' } }], + } + const nodes: TrajectoryConversationViewNode[] = [ + contribution('header:base', 2, { + kind: 'request-header', + header: { + seq: 2, + time: 2, + prompt: basePrompt, + change: { seq: 2, time: 2, kind: 'initial' }, + location: { kind: 'session' }, + }, + }), + contribution('assistant:1', 3, { + kind: 'assistant', + partial: null, + request: assistantRequest(3, 1), + }), + contribution('assistant:2', 5, { + kind: 'assistant', + partial: null, + request: assistantRequest(5, 2), + }), + contribution('header:exact', 6, { + kind: 'request-header', + header: { + seq: 6, + time: 6, + prompt: exactPrompt, + change: { seq: 6, time: 6, kind: 'system', previous: basePrompt }, + location: stepLocation(1, 2), + }, + }), + contribution('tool', 7, { + kind: 'tool', + root: { + callId: 'call-edit', + name: 'edit', + argsRaw: '{}', + turn: 1, + step: 2, + time: 7, + callView: null, + subCalls: [], + }, + }), + ] + + const snapshot = new TrajectorySnapshotBuilder().replace({ nodes }) + + expect(snapshot.requests.map(request => request.purpose === 'assistant' + ? request.prompt?.system + : undefined)).toEqual(['base prompt', 'exact prompt']) + expect(snapshot.callSchemas.get('call-edit')).toEqual(exactPrompt.tools[0]) + }) + + it('applies session boundaries and turn errors with linear request indexes', () => { + const nodes: TrajectoryConversationViewNode[] = [ + ...[assistantRequest(1, 1), assistantRequest(3, 2)].map(request => contribution( + `assistant:${request.step}`, + request.startSeq, + { kind: 'assistant', partial: null, request }, + )), + contribution('turn-end', 5, { + kind: 'turn-end', + turn: 1, + time: 5, + error: 'turn failed', + }), + contribution('compact:10', 10, { + kind: 'compaction', + request: compactionRequest(10), + }), + contribution('compact:12', 12, { + kind: 'compaction', + request: compactionRequest(12), + }), + contribution('session-end:14', 14, { kind: 'session-end', seq: 14, time: 14 }), + contribution('session-end:16', 16, { kind: 'session-end', seq: 16, time: 16 }), + ] + + const snapshot = new TrajectorySnapshotBuilder().replace({ nodes }) + + expect(snapshot.requests).toMatchObject([ + { purpose: 'assistant', step: 1, status: 'complete' }, + { purpose: 'assistant', step: 2, status: 'error', error: 'turn failed' }, + { purpose: 'compaction', startSeq: 10, status: 'error', completedAt: 16 }, + { purpose: 'compaction', startSeq: 12, status: 'error', completedAt: 14 }, + ]) + }) + + it('keeps cached contribution order across content updates and structural inserts', () => { + const builder = new TrajectorySnapshotBuilder() + const first = contribution('assistant:1', 1, { + kind: 'assistant', partial: null, request: assistantRequest(1, 1), + }) + const last = contribution('assistant:3', 5, { + kind: 'assistant', partial: null, request: assistantRequest(5, 3), + }) + expect(builder.replace({ nodes: [last, first] }).requests.map(request => request.startSeq)) + .toEqual([1, 5]) + + const updatedLast = contribution('assistant:3', 5, { + kind: 'assistant', + partial: null, + request: { ...assistantRequest(5, 3), status: 'error', error: 'failed' }, + }) + expect(builder.apply({ upserts: [updatedLast] }).requests.map(request => request.startSeq)) + .toEqual([1, 5]) + + const middle = contribution('assistant:2', 3, { + kind: 'assistant', partial: null, request: assistantRequest(3, 2), + }) + expect(builder.apply({ upserts: [middle] }).requests.map(request => request.startSeq)) + .toEqual([1, 3, 5]) + }) +}) diff --git a/packages/client/ui-trajectory/tests/table.spec.tsx b/packages/client/ui-trajectory/tests/table.spec.tsx index dc4d2c9188..b610b278f6 100644 --- a/packages/client/ui-trajectory/tests/table.spec.tsx +++ b/packages/client/ui-trajectory/tests/table.spec.tsx @@ -308,6 +308,43 @@ describe('TrajectoryTable', () => { expect(screen.getByText('Request #2')).toBeTruthy() }) + it('places the request boundary after leading steering input', () => { + const turns: readonly TrajectoryTurnModel[] = [{ + turn: 1, + groups: [{ + title: 'Step 2', + cells: [{ + index: 1, + kind: 'user', + sourceSeq: 3, + text: 'change direction', + timeSeconds: 0, + }, { + index: 2, + kind: 'message', + sourceSeq: 4, + text: 'continued', + timeSeconds: 1, + }], + }], + }] + + render() + + const request = screen.getByRole('button', { name: 'Request #1' }) + expect(request.closest('tr')?.getAttribute('aria-label')).toContain('ASSISTANT') + }) + it('follows appended records only while the ledger is already at the bottom', () => { const view = render() const tablePane = screen.getByRole('table').parentElement as HTMLElement diff --git a/packages/client/ui-trajectory/tests/toolbar.spec.tsx b/packages/client/ui-trajectory/tests/toolbar.spec.tsx new file mode 100644 index 0000000000..e2b761143a --- /dev/null +++ b/packages/client/ui-trajectory/tests/toolbar.spec.tsx @@ -0,0 +1,61 @@ +// @vitest-environment jsdom +/** Trajectory toolbar export button: click dispatch, in-flight disable, and error surfacing. */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' +import { TrajectoryToolbar, type TrajectoryToolbarProps } from '../src/client/TrajectoryToolbar.tsx' +import { zh, type TrajectoryKey } from '../src/client/locales.ts' + +/** Test translator pinned to the Simplified Chinese dictionary. */ +const zhT = (key: LocaleKeysOf<'trajectory'>): string => zh[key as TrajectoryKey] ?? key + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +function baseProps(overrides: Partial = {}): TrajectoryToolbarProps { + return { + actualDuration: false, + onActualDurationChange: vi.fn(), + actualTime: false, + onActualTimeChange: vi.fn(), + allTurnsCollapsed: false, + onToggleAllTurns: vi.fn(), + allAssistantsCollapsed: false, + onToggleAllAssistants: vi.fn(), + searchQuery: '', + onSearchQueryChange: vi.fn(), + exporting: false, + onExport: vi.fn(), + exportError: null, + t: zhT, + ...overrides, + } +} + +describe('TrajectoryToolbar export', () => { + it('renders the export button and dispatches the export callback on click', () => { + const onExport = vi.fn() + render() + const button = screen.getByRole('button', { name: 'Export session log' }) + fireEvent.click(button) + expect(onExport).toHaveBeenCalledTimes(1) + }) + + it('disables the button while an export is in flight and blocks dispatch', () => { + const onExport = vi.fn() + render() + const button = screen.getByRole('button', { name: 'Export session log' }) as HTMLButtonElement + expect(button.disabled).toBe(true) + fireEvent.click(button) + expect(onExport).not.toHaveBeenCalled() + }) + + it('surfaces an export failure as the button title', () => { + render() + const button = screen.getByRole('button', { name: 'Export session log' }) + expect(button.title).toBe('Export failed: internal boom') + }) +}) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index c9952be580..d95d70168b 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -7,18 +7,20 @@ * event ledger with its timing overview, and fiber disposal removes the tab. * Timeline projection and inclusive focus edge cases ride along. */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, fireEvent, render, screen } from '@testing-library/react' import { createElement, type ComponentProps, type FC, type ReactNode } from 'react' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { resolveSlotLabel } from '@deepseek-ai/dsh-client-ui-slots' -import { createSnapshotStore } from '@deepseek-ai/dsh-client-runtime/client' -import type { UseSession } from '@deepseek-ai/dsh-client-web-react' +import { + ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, + EMPTY_CHAT_SNAPSHOT, +} from '@deepseek-ai/dsh-client-runtime/client' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { - ConversationSnapshot, RequestView, SessionHistoryFace, SessionHistoryInspection, - SessionHistorySnapshot, SessionId, SessionListState, WorkspaceListState, + ConversationSnapshot, RequestView, + SessionId, SessionListState, SnapshotStore, WorkspaceListState, } from '@deepseek-ai/dsh-client-runtime/client' import type { ConvViewProps, ViewTab } from '@deepseek-ai/dsh-client-ui-conversation/client' import { @@ -27,6 +29,9 @@ import { } from '@deepseek-ai/dsh-client-ui-conversation/src/client/skeleton/ConversationSession.tsx' import { createChatStore } from '@deepseek-ai/dsh-client-ui-conversation/src/client/stores.ts' import { zh as conversationZh } from '@deepseek-ai/dsh-client-ui-conversation/src/client/locales.ts' +import { apply as localeApply, inject as localeInject } from '@deepseek-ai/dsh-client-locale/client' +import type { LocaleKeysOf } from '@deepseek-ai/dsh-client-ui-slots' +import { zh, type TrajectoryKey } from '../src/client/locales.ts' import { apply, inject } from '@deepseek-ai/dsh-client-ui-trajectory/client' import { apply as nodeApply } from '@deepseek-ai/dsh-client-ui-trajectory' import type { TrajectoryTurnModel } from '../src/client/layout.ts' @@ -35,9 +40,11 @@ import { TrajectoryView, type TrajectoryViewInjected, } from '../src/client/TrajectoryView.tsx' import { createTrajectoryDurationStore } from '../src/client/duration-store.ts' +import type { TrajectorySnapshot } from '../src/client/trajectory-contract.ts' import { deriveTrajectoryTimeline } from '../src/client/timeline.ts' const SID = 's1' as SessionId +const sessionSnapshots = new WeakMap>() const tConversation: ConversationSessionHeaderProps['t'] = key => (conversationZh as Record)[key] ?? key @@ -67,37 +74,54 @@ const NODES = [ function historySnapshot( nodes: ConversationSnapshot['nodes'], - inspection: Partial = {}, -): SessionHistorySnapshot { + inspection: Partial = {}, +): ConversationSnapshot { + const trajectory: TrajectorySnapshot = { + eventNodes: nodes, + eventLocations: new Map(), + requests: [], + callSchemas: new Map(), + partial: null, + runningCalls: [], + ...inspection, + } return { - state: 'ready', - error: null, - hasMore: false, - baseSeq: nodes[0]?.seq ?? 0, - inspection: { - eventNodes: nodes, - contexts: [{ id: 0, nodes }], - requests: [], - callSchemas: new Map(), - interruptedNodes: [], - partial: null, - runningCalls: [], - ...inspection, + sessionId: SID, + views: { + get: target => target === 'trajectory' ? trajectory : undefined, }, + chat: EMPTY_CHAT_SNAPSHOT, + nodes, + turnTimings: new Map(), + turnEnds: new Map(), + partial: trajectory.partial, + runningCalls: trajectory.runningCalls, + pending: [], + queue: [], + running: false, + subagent: null, + composerPhase: 'active', + removed: false, + openState: 'open', + openError: null, + hasMore: false, + loadingOlder: false, + promptError: null, + blank: nodes.length === 0, + lastAgentError: null, } } function standaloneHistory( - snapshot: SessionHistorySnapshot, + snapshot: ConversationSnapshot, ): Pick< ComponentProps, - 'useHistory' | 'loadHistoryTail' | 'loadOlderHistory' + 'useSession' | 'loadOlder' > { const store = createSnapshotStore(snapshot) return { - useHistory: bindSnapshotSelector(store), - loadHistoryTail: () => Promise.resolve(), - loadOlderHistory: () => Promise.resolve(false), + useSession: bindSnapshotSelector(store), + loadOlder: () => Promise.resolve(false), } } @@ -111,18 +135,21 @@ function standaloneDuration(): Pick< } } +function standaloneExport( + onExport: () => Promise = vi.fn(() => Promise.resolve()), +): Pick, 'exportLog'> { + return { exportLog: onExport } +} + function fakeSession(nodes: ConversationSnapshot['nodes']) { - const store = createSnapshotStore({ - nodes, pending: [], partial: null, - runningCalls: [] as ConversationSnapshot['runningCalls'], - }) - return { store, useSession: bindSnapshotSelector(store) as unknown as UseSession } + const store = createSnapshotStore(historySnapshot(nodes)) + return { store, useSession: bindSnapshotSelector(store) } } /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ function emptySessions() { const store = createSnapshotStore( - { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined }) + { ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined }) return bindSnapshotSelector(store) } @@ -135,30 +162,37 @@ function emptyWorkspaces() { } /** Standalone view props: the session-scope standard kit the outlet would bake. */ -function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { +function standaloneProps( + nodes: ConversationSnapshot['nodes'], +): ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } { return { sessionId: SID, useSession: fakeSession(nodes).useSession, useSessions: emptySessions(), useWorkspaces: emptyWorkspaces(), useProjection: (() => undefined) as never, - } as unknown as ConvViewProps + // The locale seat the outlet would inject for the declared namespace. + t: (key: LocaleKeysOf<'trajectory'>) => zh[key as TrajectoryKey] ?? key, + } as unknown as ConvViewProps & { t: (key: LocaleKeysOf<'trajectory'>) => string } } /** Real-stack bench: root Context + real SlotsService ring + the plugin fiber. */ async function bench(snapshot = historySnapshot(NODES)) { const ctx = new Context() const slots = new SlotsService(ctx) - const loadHistoryTail = vi.fn((_signal: AbortSignal) => Promise.resolve()) - const loadOlderHistory = vi.fn((_signal: AbortSignal) => Promise.resolve(false)) - const historyStore = createSnapshotStore(snapshot) - const history: SessionHistoryFace = { - sessionId: SID, - getSnapshot: () => historyStore.getSnapshot(), - subscribe: listener => historyStore.subscribe(listener), - loadTail: loadHistoryTail, - loadOlder: loadOlderHistory, + const loadOlder = vi.fn(() => Promise.resolve()) + const sessionStore = createSnapshotStore(snapshot) + const session = { + getSnapshot: () => sessionStore.getSnapshot(), + subscribe: (listener: () => void) => sessionStore.subscribe(listener), + loadOlder, } + await ctx.plugin(ConversationEventRegistry).await() + await ctx.plugin(ConversationViewRegistry).await() + ctx.provide('sessions', { + binding: () => ({ session }), + }) + sessionSnapshots.set(slots, sessionStore) // The conversation entry's role: declare the ring, then seed the chat entry. slots.register({ name: 'root', @@ -167,10 +201,13 @@ async function bench(snapshot = historySnapshot(NODES)) { const chatBody = vi.fn(() =>
) slots.register( { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) - ctx.provide('sessionHistory', { source: () => history }) + // The locale plugin backs the locale-aware view tab label ('locale' in + // inject); its settings scope needs a connection handle. + ctx.provide('connection', { api: { settings: {} }, isLoopback: false } as never) + ctx.plugin({ inject: [...localeInject], apply: localeApply }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory } + return { ctx, slots, fiber, loadOlder, sessionStore } } /** Tab projection twin of apply's viewTabs (the render-side consumption path). */ @@ -181,13 +218,8 @@ function tabsOf(slots: SlotsService): ViewTab[] { /** Mount the strict Session header/body over the ring ledger with outlet-faithful render shares. */ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES) { - const sessionSnapshot = createSnapshotStore({ - running: false, removed: false, promptError: null, nodes, - pending: [], - openState: 'open' as const, hasMore: true, loadingOlder: false, - partial: null, runningCalls: [] as ConversationSnapshot['runningCalls'], - }) - const useSession = bindSnapshotSelector(sessionSnapshot) as unknown as UseSession + const sessionSnapshot = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes)) + const useSession = bindSnapshotSelector(sessionSnapshot) const chat = createChatStore().create() const views = { list: () => tabsOf(slots), @@ -215,11 +247,11 @@ function mount(slots: SlotsService, nodes: ConversationSnapshot['nodes'] = NODES ? (() => { const trajectory = injected as TrajectoryViewInjected return { - loadHistoryTail: trajectory.loadHistoryTail, - loadOlderHistory: trajectory.loadOlderHistory, + loadOlder: trajectory.loadOlder, setActualDuration: trajectory.setActualDuration, - useHistory: bindSnapshotSelector(trajectory.hooks.history), + exportLog: trajectory.exportLog, useDuration: bindSnapshotSelector(trajectory.hooks.duration), + t: (key: TrajectoryKey) => zh[key], } })() : injected @@ -280,8 +312,16 @@ describe('plugin registration', () => { it('fiber disposal removes the tab and leaves chat standing', async () => { const b = await bench() + const events = b.ctx.get('conversationEvents') as ConversationEventRegistry + const views = b.ctx.get('conversationViews') as ConversationViewRegistry + expect(events.entries().length).toBeGreaterThan(0) + expect(views.entries()).toHaveLength(1) + await b.fiber.dispose() + expect(tabsOf(b.slots).map(v => v.id)).toEqual(['chat']) + expect(events.entries()).toEqual([]) + expect(views.entries()).toEqual([]) }) it('shares one browser-wide duration preference across session injections', async () => { @@ -301,6 +341,23 @@ describe('plugin registration', () => { expect(localStorage.getItem('dsh.trajectory.duration')).toBe('true') expect(localStorage.getItem(`dsh.trajectory.duration.${SID}`)).toBeNull() }) + + it('reports whether loading older history changed the Trajectory snapshot', async () => { + const b = await bench() + const entry = b.slots.entries('conversation.view') + .find(candidate => candidate.options.id === 'trajectory') + const injectEntry = entry!.inject as unknown as ( + sessionId: SessionId, + ) => TrajectoryViewInjected + const injected = injectEntry(SID) + + expect(await injected.loadOlder()).toBe(false) + + b.loadOlder.mockImplementationOnce(async () => { + b.sessionStore.set(historySnapshot([...NODES])) + }) + expect(await injected.loadOlder()).toBe(true) + }) }) describe('tab switching in ConversationRoot', () => { @@ -314,7 +371,7 @@ describe('tab switching in ConversationRoot', () => { expect(screen.queryByText(/turns ·/)).toBeNull() expect(view.container.querySelectorAll('tr[data-turn-start="true"]')).toHaveLength(2) expect(screen.queryByRole('columnheader')).toBeNull() - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.getByRole('region', { name: 'Trajectory timeline' })).toBeTruthy() expect(view.container.querySelector('[data-conversation-composer-overlay]')).toBeTruthy() fireEvent.click(screen.getByRole('button', { name: 'Collapse turns' })) @@ -322,13 +379,20 @@ describe('tab switching in ConversationRoot', () => { fireEvent.click(screen.getByRole('button', { name: 'Expand turns' })) expect(screen.getByRole('row', { name: /USER/ })).toBeTruthy() expect(screen.queryByTestId('chat-body')).toBeNull() - await vi.waitFor(() => { - expect(b.loadHistoryTail).toHaveBeenCalledOnce() - }) - const signal = b.loadHistoryTail.mock.calls[0]?.[0] - expect(signal?.aborted).toBe(false) + expect(b.loadOlder).not.toHaveBeenCalled() fireEvent.click(screen.getByRole('tab', { name: 'Chat' })) - expect(signal?.aborted).toBe(true) + expect(b.loadOlder).not.toHaveBeenCalled() + }) + + it('labels the trajectory tab in the active locale', async () => { + const b = await bench() + const labelOf = () => tabsOf(b.slots).find(tab => tab.id === 'trajectory')?.label + expect(labelOf()).toBe('Trajectory') + const locale = b.ctx.get('locale') as { setLocale(id: string): void } + locale.setLocale('zh') + expect(labelOf()).toBe('轨迹') + locale.setLocale('en') + expect(labelOf()).toBe('Trajectory') }) it('opens a local record inspector and switches payload tabs without opening chat details', async () => { @@ -519,7 +583,7 @@ describe('tab switching in ConversationRoot', () => { const b = await bench(historySnapshot([])) mount(b.slots) fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.getByText('No timing data')).toBeTruthy() expect(screen.getByRole('button', { name: 'Collapse turns', @@ -1066,14 +1130,63 @@ describe('timeline projection', () => { ...standaloneProps([]), ...standaloneHistory(historySnapshot([])), ...standaloneDuration(), + ...standaloneExport(), }, )) - expect(screen.getByRole('toolbar', { name: 'Trajectory toolbar' })).toBeTruthy() + expect(screen.getByRole('toolbar', { name: '轨迹工具栏' })).toBeTruthy() expect(screen.queryByRole('row')).toBeNull() }) }) -describe('TrajectoryView branches', () => { +describe('session log export', () => { + afterEach(() => { + vi.unstubAllGlobals() + Reflect.deleteProperty(URL, 'createObjectURL') + Reflect.deleteProperty(HTMLAnchorElement.prototype, 'click') + }) + + it('downloads the host-streamed ZIP with descendants on click', async () => { + // exportLog always fetches a URL instance, so the mock's shape stays narrow. + const fetchMock = vi.fn(async (input: URL) => { + expect(input.pathname).toBe('/api/session.export') + expect(input.searchParams.get('sessionId')).toBe(SID) + expect(input.searchParams.get('includeDescendants')).toBe('true') + return new Response('zip-bytes') + }) + vi.stubGlobal('fetch', fetchMock) + const createObjectURL = vi.fn(() => 'blob:export') + URL.createObjectURL = createObjectURL + const clickAnchor = vi.fn() + HTMLAnchorElement.prototype.click = clickAnchor + const b = await bench(historySnapshot(NODES)) + mount(b.slots) + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + fireEvent.click(screen.getByRole('button', { name: 'Export session log' })) + await vi.waitFor(() => { + expect(fetchMock).toHaveBeenCalledOnce() + }) + // The blob download lands a few microtasks after the fetch settles. + await vi.waitFor(() => { + expect(createObjectURL).toHaveBeenCalled() + }) + expect(clickAnchor).toHaveBeenCalled() + }) + + it('surfaces the download failure in the visible alert bar', async () => { + vi.stubGlobal('fetch', vi.fn(async () => new Response('boom', { status: 404 }))) + const b = await bench(historySnapshot(NODES)) + mount(b.slots) + fireEvent.click(screen.getByRole('tab', { name: 'Trajectory' })) + fireEvent.click(screen.getByRole('button', { name: 'Export session log' })) + await vi.waitFor(() => { + const alert = screen.queryByRole('alert') + expect(alert).not.toBeNull() + expect(alert!.textContent).toContain('HTTP 404') + }) + }) +}) + +describe('TrajectoryView state', () => { it('persists the duration preference through the runtime snapshot-store seam', () => { const firstDuration = createTrajectoryDurationStore() const commonProps = { @@ -1083,6 +1196,7 @@ describe('TrajectoryView branches', () => { const first = render( { firstDuration.set(value) }} />, @@ -1098,6 +1212,7 @@ describe('TrajectoryView branches', () => { render( { restoredDuration.set(value) }} />, @@ -1106,110 +1221,7 @@ describe('TrajectoryView branches', () => { .toBe('true') }) - it('renders only the selected rewind branch while retaining session-global requests', () => { - const retained = { - kind: 'user', - seq: 1, - time: 1_000, - content: [{ type: 'text', text: 'retained user' }], - source: null, - } as unknown as ConversationSnapshot['nodes'][number] - const abandoned = { - kind: 'assistant', - seq: 3, - time: 3_000, - turn: 1, - step: 1, - blocks: [{ kind: 'text', text: 'abandoned response' }], - } as unknown as ConversationSnapshot['nodes'][number] - const current = { - kind: 'assistant', - seq: 5, - time: 5_000, - turn: 2, - step: 1, - blocks: [{ kind: 'text', text: 'current response' }], - } as unknown as ConversationSnapshot['nodes'][number] - const request = (startSeq: number, turn: number): RequestView => ({ - purpose: 'assistant', - startSeq, - turn, - step: 1, - startedAt: startSeq * 1_000, - completedAt: startSeq * 1_000 + 100, - status: 'complete', - }) - const store = createSnapshotStore(historySnapshot( - [retained, abandoned, current], - { - eventNodes: [retained, abandoned, current], - contexts: [ - { id: 0, nodes: [retained, abandoned] }, - { - id: 1, - parentId: 0, - origin: 'rewind' as const, - originSeq: 4, - nodes: [retained, current], - }, - ], - requests: [request(2, 1), request(4, 2)], - callSchemas: new Map(), - }, - )) - const view = render( - Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} - />, - ) - - expect(screen.queryByText('abandoned response')).toBeNull() - expect(screen.getByText('current response')).toBeTruthy() - expect(screen.getByRole('row', { name: /Request 2, ASSISTANT/ })).toBeTruthy() - expect(view.container.querySelectorAll('[data-request-only="true"]')).toHaveLength(0) - }) - - it('does not remount the ledger when prepending shifts a rewind generation id', () => { - const current = { - kind: 'assistant', - seq: 5, - time: 5_000, - turn: 2, - step: 1, - blocks: [{ kind: 'text', text: 'stable rewind response' }], - } as unknown as ConversationSnapshot['nodes'][number] - const snapshot = (id: number) => historySnapshot([current], { - contexts: [{ - id, - origin: 'rewind' as const, - originSeq: 4, - nodes: [current], - }], - }) - const store = createSnapshotStore(snapshot(1)) - render( - Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} - />, - ) - const row = screen.getByRole('row', { name: /stable rewind response/ }) - fireEvent.click(row) - expect(row.getAttribute('aria-selected')).toBe('true') - - act(() => { store.set(snapshot(2)) }) - - expect(screen.getByRole('row', { name: /stable rewind response/ }) - .getAttribute('aria-selected')).toBe('true') - }) it('keeps ledger and timeline selection on the same event after prepend', () => { const older = { @@ -1225,9 +1237,9 @@ describe('TrajectoryView branches', () => { Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} + {...standaloneExport()} + useSession={bindSnapshotSelector(store)} + loadOlder={vi.fn(() => Promise.resolve(false))} />, ) fireEvent.click(screen.getByRole('row', { name: /selected current response/ })) @@ -1242,47 +1254,6 @@ describe('TrajectoryView branches', () => { )).toBeTruthy() }) - it('retains cancellation-frozen assistant and tool nodes outside raw contexts', () => { - const retained = { - kind: 'user', seq: 1, time: 1_000, - content: [{ type: 'text', text: 'stop the task' }], source: null, - } as unknown as ConversationSnapshot['nodes'][number] - const interruptedAssistant = { - kind: 'assistant', seq: 2.1, time: 2_000, turn: 1, step: 1, - blocks: [{ kind: 'text', text: 'partial response retained' }], - interrupted: true, - } as unknown as ConversationSnapshot['nodes'][number] - const interruptedTool = { - kind: 'tool-result', seq: 2.2, time: 2_100, callId: 'slow-call', - call: { name: 'bash', argsRaw: '{"command":"sleep 30"}' }, callTime: 1_900, - content: [], isError: true, - error: { name: 'Interrupted', code: 'interrupted' }, - callView: null, resultView: null, - } as unknown as ConversationSnapshot['nodes'][number] - const store = createSnapshotStore(historySnapshot( - [retained], - { - eventNodes: [retained], - contexts: [{ id: 0, nodes: [retained] }], - requests: [], - callSchemas: new Map(), - interruptedNodes: [interruptedAssistant, interruptedTool], - }, - )) - - render( - Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} - />, - ) - - expect(screen.getByText('partial response retained')).toBeTruthy() - expect(screen.getByRole('row', { name: /TOOL, bash/ })).toBeTruthy() - }) }) describe('node half', () => { diff --git a/packages/client/ui-trajectory/tsconfig.json b/packages/client/ui-trajectory/tsconfig.json index f525474d9d..29ff1c3357 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -11,6 +11,9 @@ { "path": "../../../vendor/cordis" }, + { + "path": "../locale" + }, { "path": "../ui-conversation" }, @@ -20,6 +23,15 @@ { "path": "../runtime" }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../compact/compact" + }, { "path": "../../support/invariants" } diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 197369cd48..f19c44ea57 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-ui-workspace", "description": "Workspace picker plugin: one WorkspacePicker registered into the sidebar and empty-state workspace slots", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/ui-workspace" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -42,12 +49,12 @@ "clsx": "^2.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -60,7 +67,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css index 6052b5075f..6c6c44c2c7 100644 --- a/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css +++ b/packages/client/ui-workspace/src/client/WorkspaceBrowser.module.css @@ -64,7 +64,8 @@ line-height: 20px; } -/* Search input: 38px capsule (figma 133:7649); rail state renders it as the +/* Search input: 38px bar, 12px radius (figma 133:7649 geometry, squared-off + corners); rail state renders it as the region's search control. Upstream binds a dedicated design-system variable (light #F1F3F5 / dark #1B1B1C) matching no shipped alias — a component token pinned to the static scale mirrors it. */ @@ -79,7 +80,7 @@ padding: 0 14px; box-sizing: border-box; border: 1px solid var(--dsw-alias-border-l2); - border-radius: 24px; + border-radius: 12px; background: var(--dsh-search-input-fill); color: var(--dsw-alias-label-caption); overflow: hidden; diff --git a/packages/client/ui-workspace/src/invariant.ts b/packages/client/ui-workspace/src/invariant.ts index d3f0df2cdc..4a15d37998 100644 --- a/packages/client/ui-workspace/src/invariant.ts +++ b/packages/client/ui-workspace/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-workspace' diff --git a/packages/client/ui-workspace/tests/apply.spec.ts b/packages/client/ui-workspace/tests/apply.spec.ts index d6dfe8d185..44645a9ee3 100644 --- a/packages/client/ui-workspace/tests/apply.spec.ts +++ b/packages/client/ui-workspace/tests/apply.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { LocaleService } from '@deepseek-ai/dsh-client-locale/client' diff --git a/packages/client/ui-workspace/tests/invariant.spec.ts b/packages/client/ui-workspace/tests/invariant.spec.ts index 0606c94e09..8773f754df 100644 --- a/packages/client/ui-workspace/tests/invariant.spec.ts +++ b/packages/client/ui-workspace/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as WorkspaceInvariant from '@deepseek-ai/dsh-client-ui-workspace/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/client/ui-workspace/tests/tree.spec.ts b/packages/client/ui-workspace/tests/tree.spec.ts index 647e29f6d3..5a6d145e36 100644 --- a/packages/client/ui-workspace/tests/tree.spec.ts +++ b/packages/client/ui-workspace/tests/tree.spec.ts @@ -17,7 +17,7 @@ const list = (...items: SessionSummary[]): SessionListState => ({ ids: items.map(item => item.id), byId: Object.fromEntries(items.map(item => [item.id, item])), current: undefined, - phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, }) const workspace = (id: string, sessionIds: string[], title = id): WorkspaceView => ({ workspaceId: wid(id), path: `/projects/${id}`, title, diff --git a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx index 6c46275894..918a3f6c0e 100644 --- a/packages/client/ui-workspace/tests/workspace-browser.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-browser.spec.tsx @@ -29,7 +29,7 @@ const sessionState = (items: readonly SessionSummary[], overrides: Partial [item.id, item])), current: undefined, phase: 'ready', - subagentsByParent: {}, + subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, ...overrides, }) diff --git a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx index e7788a3f63..132b64f4b2 100644 --- a/packages/client/ui-workspace/tests/workspace-picker.spec.tsx +++ b/packages/client/ui-workspace/tests/workspace-picker.spec.tsx @@ -28,7 +28,7 @@ function hook(snapshot: T) { return function select(selector: (state: T) => S): S { return selector(snapshot) } } const sessions: SessionListState = { - ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, currentAddress: undefined, + ids: [], byId: {}, current: undefined, phase: 'ready', subagentsByParent: {}, tasksBySession: {}, currentAddress: undefined, } const workspaceState = (items: readonly WorkspaceView[]): WorkspaceListState => ({ items, archivedSessionIds: [], state: 'idle', phase: 'ready', error: null, baselinesReady: true, diff --git a/packages/client/web-react/package.json b/packages/client/web-react/package.json index b5e242a00e..16bab468df 100644 --- a/packages/client/web-react/package.json +++ b/packages/client/web-react/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-web-react", "description": "Shell-side React glue: createSlotRenderer, SessionProvider, bindSnapshotSelector (uSES bridge), useInvoke", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/web-react" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,13 +32,13 @@ "use-sync-external-store": "1.2.0" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/web-react/src/invariant.ts b/packages/client/web-react/src/invariant.ts index aff9c09cd3..6d4fec6566 100644 --- a/packages/client/web-react/src/invariant.ts +++ b/packages/client/web-react/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-web-react' diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 847d126386..df5192e070 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-client-web", "description": "Web shell kernel: bootWebShell (module system holding + seed table + two-stage boot + AppRoot gate + app-shell assembly entry), consumed by the apps/web vite entry", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/client/web" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -30,19 +37,19 @@ "react-dom": "^18.2.0" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-client-runtime": "workspace:^", "@deepseek-ai/dsh-client-test-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "typescript": "^6.0.3" }, "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/web/src/app-shell.ts b/packages/client/web/src/app-shell.ts index 140fd49c60..3ab76c54ba 100644 --- a/packages/client/web/src/app-shell.ts +++ b/packages/client/web/src/app-shell.ts @@ -3,7 +3,7 @@ * graph and shell registry; there is no npm package behind it. */ import type { ReactNode } from 'react' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { createSlotRenderer } from '@deepseek-ai/dsh-client-web-react' import { buildRenderApp } from './app.tsx' @@ -16,7 +16,7 @@ export interface AppShellService { renderApp: () => ReactNode } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The shell assembly face, provided by the app-shell entry once its inject set is active. */ appShell: AppShellService diff --git a/packages/client/web/src/app.tsx b/packages/client/web/src/app.tsx index 5e0010301c..646dcec3dc 100644 --- a/packages/client/web/src/app.tsx +++ b/packages/client/web/src/app.tsx @@ -6,7 +6,7 @@ * the program. */ import type { ReactNode } from 'react' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { bindSnapshotSelector } from '@deepseek-ai/dsh-client-web-react' import { DocumentTitle } from './DocumentTitle.tsx' // Type-only: pulls the runtime's SlotMap declaration merge (the 'root' key) into this program. diff --git a/packages/client/web/src/boot.tsx b/packages/client/web/src/boot.tsx index c1203929f9..7afc298767 100644 --- a/packages/client/web/src/boot.tsx +++ b/packages/client/web/src/boot.tsx @@ -32,8 +32,8 @@ * decisions (the app-shell assembly is itself a graph entry, the only * shell-own module registered with the module system). */ -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { createRoot, type Root } from 'react-dom/client' import * as ModulesClient from '@deepseek-ai/dsh-client-modules/client' import { diff --git a/packages/client/web/src/invariant.ts b/packages/client/web/src/invariant.ts index 7b9fa6292c..8964082cc8 100644 --- a/packages/client/web/src/invariant.ts +++ b/packages/client/web/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-client-web' diff --git a/packages/client/web/src/loader-status.ts b/packages/client/web/src/loader-status.ts index 03ac761afe..8f8d250ef0 100644 --- a/packages/client/web/src/loader-status.ts +++ b/packages/client/web/src/loader-status.ts @@ -10,7 +10,7 @@ * the loading page has to work while (and especially when) plugins fail. * @module @deepseek-ai/dsh-client-web/src/loader-status */ -import type { FiberState } from 'cordis' +import type { FiberState } from '@deepseek-ai/cordis' /** * Value mirror of cordis's `FiberState` const enum: a const enum has no diff --git a/packages/client/web/src/platform.ts b/packages/client/web/src/platform.ts index dc6b9e58ed..e7997cf728 100644 --- a/packages/client/web/src/platform.ts +++ b/packages/client/web/src/platform.ts @@ -6,7 +6,7 @@ /** The module specifiers the shell shares into the frozen module table. */ export const PLATFORM_MODULES = [ - 'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', 'cordis', + 'react', 'react/jsx-runtime', 'react-dom', 'react-dom/client', '@deepseek-ai/cordis', '@deepseek-ai/dsh-client-ui-slots', '@deepseek-ai/dsh-client-web-react', '@deepseek-ai/dsh-client-ui-primitives', diff --git a/packages/client/web/src/seed.ts b/packages/client/web/src/seed.ts index fd5360f0f7..868b0fa058 100644 --- a/packages/client/web/src/seed.ts +++ b/packages/client/web/src/seed.ts @@ -10,7 +10,7 @@ import * as React from 'react' import * as ReactJsxRuntime from 'react/jsx-runtime' import * as ReactDom from 'react-dom' import * as ReactDomClient from 'react-dom/client' -import * as Cordis from 'cordis' +import * as Cordis from '@deepseek-ai/cordis' import * as UiSlots from '@deepseek-ai/dsh-client-ui-slots' import * as WebReact from '@deepseek-ai/dsh-client-web-react' import * as UiPrimitives from '@deepseek-ai/dsh-client-ui-primitives' @@ -30,7 +30,7 @@ export function getStaticModules(): Record { 'react/jsx-runtime': ReactJsxRuntime, 'react-dom': ReactDom, 'react-dom/client': ReactDomClient, - 'cordis': Cordis, + '@deepseek-ai/cordis': Cordis, '@deepseek-ai/dsh-client-ui-slots': UiSlots, '@deepseek-ai/dsh-client-web-react': WebReact, '@deepseek-ai/dsh-client-ui-primitives': UiPrimitives, diff --git a/packages/client/web/tests/app-shell.spec.tsx b/packages/client/web/tests/app-shell.spec.tsx index 10acab85d8..445ba787f8 100644 --- a/packages/client/web/tests/app-shell.spec.tsx +++ b/packages/client/web/tests/app-shell.spec.tsx @@ -8,7 +8,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import { TestSessions, TestWorkspaces } from '@deepseek-ai/dsh-client-test-runtime' import type { Stabilizer } from '@deepseek-ai/dsh-client-test-runtime' diff --git a/packages/client/web/tests/app.spec.tsx b/packages/client/web/tests/app.spec.tsx index b8f5cb2fde..70bdf38611 100644 --- a/packages/client/web/tests/app.spec.tsx +++ b/packages/client/web/tests/app.spec.tsx @@ -6,7 +6,7 @@ */ import { afterEach, describe, expect, it } from 'vitest' import { cleanup, render } from '@testing-library/react' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SlotTestRuntime } from '@deepseek-ai/dsh-client-test-runtime' import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import { buildRenderApp } from '@deepseek-ai/dsh-client-web/src/app.tsx' diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index cc72bf1b12..2614be990a 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/code-runtime/code-runtime-worker" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -29,20 +36,20 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-code-runtime": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-code-runtime": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/code-runtime/code-runtime-worker/src/index.ts b/packages/code-runtime/code-runtime-worker/src/index.ts index 8193cb587e..df3c3ff98f 100644 --- a/packages/code-runtime/code-runtime-worker/src/index.ts +++ b/packages/code-runtime/code-runtime-worker/src/index.ts @@ -10,8 +10,8 @@ import { Worker } from 'node:worker_threads' import { stripTypeScriptTypes } from 'node:module' import type { Readable } from 'node:stream' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { CodeRuntime, DUNDER_MEMBER, PORTABLE_RESERVED_WORDS, RESERVED_BINDING_GLOBALS, RESERVED_ERROR_MEMBERS } from '@deepseek-ai/dsh-code-runtime' import type { CodeBindingNamespace, CodeJsonValue, CodeRunFailure, CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' diff --git a/packages/code-runtime/code-runtime-worker/src/invariant.ts b/packages/code-runtime/code-runtime-worker/src/invariant.ts index 3455104441..4569372efb 100644 --- a/packages/code-runtime/code-runtime-worker/src/invariant.ts +++ b/packages/code-runtime/code-runtime-worker/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime-worker' diff --git a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts index 5a09dd69f2..f0903002f1 100644 --- a/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts +++ b/packages/code-runtime/code-runtime-worker/tests/built-lib.e2e.ts @@ -18,7 +18,7 @@ const built = ['lib/index.js', 'lib/worker.cjs'].every(file => existsSync(join(p describe.skipIf(!built)('built lib real load path (plain node)', () => { it('runs a TypeScript program with a binding through lib/index.js and its lib/worker.cjs entry', async () => { const script = ` - const { Context } = await import('cordis') + const { Context } = await import('@deepseek-ai/cordis') const { WorkerCodeRuntime } = await import('@deepseek-ai/dsh-code-runtime-worker') const ctx = new Context() await ctx.plugin(WorkerCodeRuntime, {}) diff --git a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts index 54f58eb414..d15bd9f8bd 100644 --- a/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts +++ b/packages/code-runtime/code-runtime-worker/tests/runtime.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { WorkerCodeRuntime } from '@deepseek-ai/dsh-code-runtime-worker' import type { Config } from '@deepseek-ai/dsh-code-runtime-worker' import type { CodeBindingFunction, CodeBindingNamespace, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index f6e3a08ce1..8d4e77136b 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/code-runtime/code-runtime" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/code-runtime/code-runtime/src/index.ts b/packages/code-runtime/code-runtime/src/index.ts index 9a7716aea0..c23143f821 100644 --- a/packages/code-runtime/code-runtime/src/index.ts +++ b/packages/code-runtime/code-runtime/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-code-runtime */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { CodeRunRequest, CodeRunResult } from './types.ts' export type { @@ -86,7 +86,7 @@ export const PORTABLE_RESERVED_WORDS: ReadonlySet = new Set([ 'global', 'is', 'lambda', 'nonlocal', 'not', 'or', 'pass', 'raise', 'match', 'type', '_', ]) -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { codeRuntime: CodeRuntime } diff --git a/packages/code-runtime/code-runtime/src/invariant.ts b/packages/code-runtime/code-runtime/src/invariant.ts index 9c4019699b..5f234691e0 100644 --- a/packages/code-runtime/code-runtime/src/invariant.ts +++ b/packages/code-runtime/code-runtime/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-code-runtime' diff --git a/packages/code-runtime/code-runtime/tests/service.spec.ts b/packages/code-runtime/code-runtime/tests/service.spec.ts index 7ea3a30b31..44e356329a 100644 --- a/packages/code-runtime/code-runtime/tests/service.spec.ts +++ b/packages/code-runtime/code-runtime/tests/service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' diff --git a/packages/compact/command-compact/package.json b/packages/compact/command-compact/package.json index 324051b188..83df4eae98 100644 --- a/packages/compact/command-compact/package.json +++ b/packages/compact/command-compact/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/compact/command-compact" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,20 +32,20 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-commands": "^0.0.1", - "@deepseek-ai/dsh-compact": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/compact/command-compact/src/index.ts b/packages/compact/command-compact/src/index.ts index b3f80d87fd..01639a780a 100644 --- a/packages/compact/command-compact/src/index.ts +++ b/packages/compact/command-compact/src/index.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-command-compact */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { ManualCompactionError } from '@deepseek-ai/dsh-compact' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' diff --git a/packages/compact/command-compact/src/invariant.ts b/packages/compact/command-compact/src/invariant.ts index 09b3c04d8d..903c9d4375 100644 --- a/packages/compact/command-compact/src/invariant.ts +++ b/packages/compact/command-compact/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-command-compact' diff --git a/packages/compact/command-compact/tests/command-compact.spec.ts b/packages/compact/command-compact/tests/command-compact.spec.ts index 0c419d6799..ba09e5c42a 100644 --- a/packages/compact/command-compact/tests/command-compact.spec.ts +++ b/packages/compact/command-compact/tests/command-compact.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import CommandService, { type CommandResult } from '@deepseek-ai/dsh-commands' import { diff --git a/packages/compact/command-compact/tests/loader-composition.spec.ts b/packages/compact/command-compact/tests/loader-composition.spec.ts index 5f7fd0b346..10033d27af 100644 --- a/packages/compact/command-compact/tests/loader-composition.spec.ts +++ b/packages/compact/command-compact/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } 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 Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import type { Agent } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import { diff --git a/packages/compact/compact-basic/README.i18n.yaml b/packages/compact/compact-basic/README.i18n.yaml index 75e9e7be19..146170b779 100644 --- a/packages/compact/compact-basic/README.i18n.yaml +++ b/packages/compact/compact-basic/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/compact/compact-basic/README.md -README.md: 97c71cafe1eba7dd29c903535b378439586e807e -README.zh.md: 55a0080fe3c08e5559d737d46f57e23645a52cad +README.md: 4812f858b4773d8cc5ea6a1543430c08f858374f +README.zh.md: 189c5cad5e98533d2dad217404c8c2bcaf41b94b diff --git a/packages/compact/compact-basic/README.md b/packages/compact/compact-basic/README.md index 97c71cafe1..4812f858b4 100644 --- a/packages/compact/compact-basic/README.md +++ b/packages/compact/compact-basic/README.md @@ -49,7 +49,7 @@ An adapter may return no capacity for a valid dynamic route, and resolved capaci `BasicCompactService` requires `ctx.llm`, `ctx.tokenMeter`, and `ctx.sessions`. The composition below receives `ctx.llm` from its host and installs the other two services: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import SessionStore from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' diff --git a/packages/compact/compact-basic/README.zh.md b/packages/compact/compact-basic/README.zh.md index 55a0080fe3..189c5cad5e 100644 --- a/packages/compact/compact-basic/README.zh.md +++ b/packages/compact/compact-basic/README.zh.md @@ -49,7 +49,7 @@ `BasicCompactService` 需要 `ctx.llm`、`ctx.tokenMeter` 和 `ctx.sessions`。以下组合从其宿主接收 `ctx.llm`,并安装另外两项服务: ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { BasicCompactService } from '@deepseek-ai/dsh-compact-basic' import SessionStore from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 3fffee14a7..75e0dcd959 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-compact-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/compact/compact-basic" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-compact": "^0.0.1", - "@deepseek-ai/dsh-commands": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-token-meter": "^0.0.1", - "@deepseek-ai/dsh-compact-tool-result-prune": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-compact-tool-result-prune": { @@ -41,11 +48,11 @@ } }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -58,6 +65,6 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/compact/compact-basic/src/index.ts b/packages/compact/compact-basic/src/index.ts index d3c710bef3..773f31d976 100644 --- a/packages/compact/compact-basic/src/index.ts +++ b/packages/compact/compact-basic/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-compact-basic */ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { CompactService, ManualCompactionError } from '@deepseek-ai/dsh-compact' import type { CompactionResult, CompactionTrigger } from '@deepseek-ai/dsh-compact' import type { TokenMeterService } from '@deepseek-ai/dsh-token-meter' diff --git a/packages/compact/compact-basic/src/invariant.ts b/packages/compact/compact-basic/src/invariant.ts index 172790d233..4818d6b5a6 100644 --- a/packages/compact/compact-basic/src/invariant.ts +++ b/packages/compact/compact-basic/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-compact-basic' diff --git a/packages/compact/compact-basic/src/summarizer.ts b/packages/compact/compact-basic/src/summarizer.ts index 681919905e..99e164ce12 100644 --- a/packages/compact/compact-basic/src/summarizer.ts +++ b/packages/compact/compact-basic/src/summarizer.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-compact-basic/summarizer */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { contentHasImage, createUserMessage, BlockAssembler, LlmError } from '@deepseek-ai/dsh-llm' import type { ContentBlock, FinishReason, GenerateOptions, Message, TokenUsage, ToolSchema, diff --git a/packages/compact/compact-basic/tests/compact-basic.spec.ts b/packages/compact/compact-basic/tests/compact-basic.spec.ts index b3e088c82a..4d1973326a 100644 --- a/packages/compact/compact-basic/tests/compact-basic.spec.ts +++ b/packages/compact/compact-basic/tests/compact-basic.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { AttachmentId } from '@deepseek-ai/dsh-attachment' import BasicCompactService from '@deepseek-ai/dsh-compact-basic' import type { BasicCompactConfig } from '@deepseek-ai/dsh-compact-basic' diff --git a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts index 471207aa97..536ef9d543 100644 --- a/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts +++ b/packages/compact/compact-basic/tests/compact-loop-repro.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { toolPairingBalancedAfter, toolPairingBalancedBefore } from '@deepseek-ai/dsh-compact' import { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, LlmError, resolveRetryPolicy , createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, GenerateOptions, LlmResolvedModelInfo, ResolvedRetryPolicy, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/compact/compact-basic/tests/loader-composition.spec.ts b/packages/compact/compact-basic/tests/loader-composition.spec.ts index 162c44efd4..2284d54cf9 100644 --- a/packages/compact/compact-basic/tests/loader-composition.spec.ts +++ b/packages/compact/compact-basic/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } 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 Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import TokenMeterService from '@deepseek-ai/dsh-token-meter' diff --git a/packages/compact/compact-basic/tests/manual-compact.spec.ts b/packages/compact/compact-basic/tests/manual-compact.spec.ts index 0599d3f364..16cf680e1d 100644 --- a/packages/compact/compact-basic/tests/manual-compact.spec.ts +++ b/packages/compact/compact-basic/tests/manual-compact.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/compact/compact-tool-result-prune/README.i18n.yaml b/packages/compact/compact-tool-result-prune/README.i18n.yaml index 78eb863c26..ebe54ccf58 100644 --- a/packages/compact/compact-tool-result-prune/README.i18n.yaml +++ b/packages/compact/compact-tool-result-prune/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/compact/compact-tool-result-prune/README.md -README.md: edeba52b189b3cee5530faf7efc04043a326917f -README.zh.md: abc19afa784ec1121430b57a9d90d22d12b89810 +README.md: 8ebc422f37db1f86adedcb207f03eefc75b8d242 +README.zh.md: 8f8920bbc2b6014f98a3d82a4501ae33e367f39c diff --git a/packages/compact/compact-tool-result-prune/README.md b/packages/compact/compact-tool-result-prune/README.md index edeba52b18..8ebc422f37 100644 --- a/packages/compact/compact-tool-result-prune/README.md +++ b/packages/compact/compact-tool-result-prune/README.md @@ -31,7 +31,7 @@ All values are integers; the threshold is positive and head/tail are non-negativ ## Usage ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' export function apply(ctx: Context): void { diff --git a/packages/compact/compact-tool-result-prune/README.zh.md b/packages/compact/compact-tool-result-prune/README.zh.md index abc19afa78..8f8920bbc2 100644 --- a/packages/compact/compact-tool-result-prune/README.zh.md +++ b/packages/compact/compact-tool-result-prune/README.zh.md @@ -31,7 +31,7 @@ ## 用法 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' export function apply(ctx: Context): void { diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json index bc38eccdb0..26f216a4dd 100644 --- a/packages/compact/compact-tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-compact-tool-result-prune", "description": "Replay-safe model-free head/middle/tail pruning for tool-result surface nodes", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/compact/compact-tool-result-prune" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,24 +32,24 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-compact": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-token-meter": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "dependencies": { - "schemastery": "^3.18.0" - }, - "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-token-meter": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/compact/compact-tool-result-prune/src/index.ts b/packages/compact/compact-tool-result-prune/src/index.ts index 48e3ee308b..89e27108e9 100644 --- a/packages/compact/compact-tool-result-prune/src/index.ts +++ b/packages/compact/compact-tool-result-prune/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-compact-tool-result-prune */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { freezeMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { Session, SessionEvent, ToolResultMessage } from '@deepseek-ai/dsh-session' @@ -29,7 +29,7 @@ export type { ToolResultPruneConfig, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { toolResultPrune: ToolResultPruneService } diff --git a/packages/compact/compact-tool-result-prune/src/invariant.ts b/packages/compact/compact-tool-result-prune/src/invariant.ts index 8c2b0a1133..199a4c9073 100644 --- a/packages/compact/compact-tool-result-prune/src/invariant.ts +++ b/packages/compact/compact-tool-result-prune/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-compact-tool-result-prune' diff --git a/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts index fbc4b840c9..9db0f0e976 100644 --- a/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } 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 Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import TokenMeterService from '@deepseek-ai/dsh-token-meter' import ToolResultPruneService from '@deepseek-ai/dsh-compact-tool-result-prune' diff --git a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts index 69a28eb123..672abf1286 100644 --- a/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts +++ b/packages/compact/compact-tool-result-prune/tests/tool-result-prune.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SessionStore, { diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index 860145e542..6f484bf8f6 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-compact", "description": "Abstract compaction service seam (ctx.compact) for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/compact/compact" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -34,12 +41,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-commands": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -47,6 +54,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/compact/compact/src/index.ts b/packages/compact/compact/src/index.ts index 6fb9706c04..d3be6f5750 100644 --- a/packages/compact/compact/src/index.ts +++ b/packages/compact/compact/src/index.ts @@ -7,7 +7,7 @@ * @module @deepseek-ai/dsh-compact */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { Session } from '@deepseek-ai/dsh-session' import type { CommandId } from '@deepseek-ai/dsh-commands/brand' import type { CompactionResult } from './types.ts' @@ -78,7 +78,7 @@ export interface ManualCompactAgentContext extends CompactAgentContext { runMaintenance(task: (signal: AbortSignal) => Promise): Promise } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { compact: CompactService } diff --git a/packages/compact/compact/src/invariant.ts b/packages/compact/compact/src/invariant.ts index adac557db5..1942bc8510 100644 --- a/packages/compact/compact/src/invariant.ts +++ b/packages/compact/compact/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned compaction log-stream invariants. @module @deepseek-ai/dsh-compact/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/compact/compact/tests/compact.spec.ts b/packages/compact/compact/tests/compact.spec.ts index 74e2bd0270..c9af4ed2e0 100644 --- a/packages/compact/compact/tests/compact.spec.ts +++ b/packages/compact/compact/tests/compact.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CompactionId, CompactService, diff --git a/packages/compact/compact/tests/invariant.spec.ts b/packages/compact/compact/tests/invariant.spec.ts index 1637c16179..e49fe8ec3e 100644 --- a/packages/compact/compact/tests/invariant.spec.ts +++ b/packages/compact/compact/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import { createUserMessage } from '@deepseek-ai/dsh-llm' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 48111ca09b..c9dcb6af03 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferences)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/context/session-reference" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-compact": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-retention": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-query": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-retention": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -45,6 +52,6 @@ "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/context/session-reference/src/index.ts b/packages/context/session-reference/src/index.ts index 01a6965027..792f0abd20 100644 --- a/packages/context/session-reference/src/index.ts +++ b/packages/context/session-reference/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-session-reference */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, UserMessage } from '@deepseek-ai/dsh-llm' @@ -50,7 +50,7 @@ user explicitly repeats them. ` const PROMPT_SUFFIX = '\n' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessionReferences: SessionReferenceService } diff --git a/packages/context/session-reference/src/invariant.ts b/packages/context/session-reference/src/invariant.ts index c8a5b0b5c3..9a277e7614 100644 --- a/packages/context/session-reference/src/invariant.ts +++ b/packages/context/session-reference/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-reference' diff --git a/packages/context/session-reference/tests/session-reference.spec.ts b/packages/context/session-reference/tests/session-reference.spec.ts index ce964af16a..9f9dee8bb8 100644 --- a/packages/context/session-reference/tests/session-reference.spec.ts +++ b/packages/context/session-reference/tests/session-reference.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { CompactionId, compactCheckpointSource } from '@deepseek-ai/dsh-compact' import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 8e8421fd01..c1c222bd6f 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/context/time-context" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,13 +32,13 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -43,6 +50,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/context/time-context/src/index.ts b/packages/context/time-context/src/index.ts index 5e95beb2b2..977d73cd35 100644 --- a/packages/context/time-context/src/index.ts +++ b/packages/context/time-context/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-time-context */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/context/time-context/src/invariant.ts b/packages/context/time-context/src/invariant.ts index ffd3fd22a8..8a1a888e4c 100644 --- a/packages/context/time-context/src/invariant.ts +++ b/packages/context/time-context/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned durable clock-context invariants. @module @deepseek-ai/dsh-time-context/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/context/time-context/tests/invariant.spec.ts b/packages/context/time-context/tests/invariant.spec.ts index b5f0385b3f..02a04eb863 100644 --- a/packages/context/time-context/tests/invariant.spec.ts +++ b/packages/context/time-context/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as TimeInvariant from '@deepseek-ai/dsh-time-context/invariant' diff --git a/packages/context/time-context/tests/time-context.spec.ts b/packages/context/time-context/tests/time-context.spec.ts index a0ffb9e619..5e50b38136 100644 --- a/packages/context/time-context/tests/time-context.spec.ts +++ b/packages/context/time-context/tests/time-context.spec.ts @@ -1,6 +1,6 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { createUserMessage, CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 524036a667..6a4d7b81d8 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tmux-context", "description": "Opt-in durable per-step context with this agent's tmux pane and window location", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/context/tmux-context" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,14 +32,14 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -41,6 +48,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/context/tmux-context/src/index.ts b/packages/context/tmux-context/src/index.ts index c489ec0970..8eac866637 100644 --- a/packages/context/tmux-context/src/index.ts +++ b/packages/context/tmux-context/src/index.ts @@ -18,8 +18,8 @@ * @module @deepseek-ai/dsh-tmux-context */ -import type { Context, LoggerService } from 'cordis' -import z from 'schemastery' +import type { Context, LoggerService } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import type { BashExecutor, BashRunResult } from '@deepseek-ai/dsh-bash' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/context/tmux-context/src/invariant.ts b/packages/context/tmux-context/src/invariant.ts index 181f1a2289..901f7c4043 100644 --- a/packages/context/tmux-context/src/invariant.ts +++ b/packages/context/tmux-context/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tmux-context' diff --git a/packages/context/tmux-context/tests/tmux-context.spec.ts b/packages/context/tmux-context/tests/tmux-context.spec.ts index 12bc37b9ce..0b28486019 100644 --- a/packages/context/tmux-context/tests/tmux-context.spec.ts +++ b/packages/context/tmux-context/tests/tmux-context.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { agentEvents, Inbox, type Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index 6548fb49a8..9181b2aded 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-workspace-context", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/context/workspace-context" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,20 +32,20 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", @@ -51,6 +58,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/context/workspace-context/src/config.ts b/packages/context/workspace-context/src/config.ts index c1a1fad1e6..cc3d9d7bbf 100644 --- a/packages/context/workspace-context/src/config.ts +++ b/packages/context/workspace-context/src/config.ts @@ -5,7 +5,7 @@ */ import { relative } from 'node:path' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { resolveDshHome } from '@deepseek-ai/dsh-paths' const DEFAULT_PROJECT_ROOT_MARKERS = ['.git'] as const diff --git a/packages/context/workspace-context/src/index.ts b/packages/context/workspace-context/src/index.ts index fee08f2c08..22611d2658 100644 --- a/packages/context/workspace-context/src/index.ts +++ b/packages/context/workspace-context/src/index.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-workspace-context */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { isDeepStrictEqual } from 'node:util' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/context/workspace-context/src/invariant.ts b/packages/context/workspace-context/src/invariant.ts index f6c99ea10e..a3860cb122 100644 --- a/packages/context/workspace-context/src/invariant.ts +++ b/packages/context/workspace-context/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workspace-context' diff --git a/packages/context/workspace-context/tests/workspace-context.e2e.ts b/packages/context/workspace-context/tests/workspace-context.e2e.ts index c1151428e2..0ed6d812ac 100644 --- a/packages/context/workspace-context/tests/workspace-context.e2e.ts +++ b/packages/context/workspace-context/tests/workspace-context.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index a40d3aec9a..e95b30fa5b 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -2,8 +2,8 @@ import { chmod, mkdtemp, mkdir, rm, stat, symlink, utimes, writeFile } from 'nod import { dirname, join, resolve } from 'node:path' import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' import LlmService, { createUserMessage, CallId, type Message, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, SESSION_FORMAT_VERSION, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' @@ -117,6 +117,10 @@ class RecordingFileSystem extends FileSystem { return this.entries.get(target.targetKey)?.content ?? '' } + override async readBytes(_target: FsTarget, _signal: AbortSignal | undefined, _maxBytes: number): Promise { + throw new Error('not needed in workspace-context tests') + } + override async streamText(target: FsTarget, signal?: AbortSignal): Promise> { if (signal !== undefined) this.signals.push(signal) signal?.throwIfAborted() diff --git a/packages/core/agent-default-model/README.i18n.yaml b/packages/core/agent-default-model/README.i18n.yaml index 7835a159bc..c84c0ea271 100644 --- a/packages/core/agent-default-model/README.i18n.yaml +++ b/packages/core/agent-default-model/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/core/agent-default-model/README.md -README.md: 98bc7d082e62a764868f8acd323c4617e9839e61 -README.zh.md: 807b612bd25e49aa318c13c8c8dc7595a6459080 +README.md: e86be7c37a1f994ca52f018144ef6a2409bd1eea +README.zh.md: 00250c28ef8c03d4b33fe1c1bfca138a022f6638 diff --git a/packages/core/agent-default-model/README.md b/packages/core/agent-default-model/README.md index 98bc7d082e..e86be7c37a 100644 --- a/packages/core/agent-default-model/README.md +++ b/packages/core/agent-default-model/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh run` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults. +The deployment default used when an entry point creates an Agent that has no session-local model selection. `AgentDefaultModelService` provides `ctx.agentDefaultModel`; direct entry points such as `dsh --profile headless` and Host-backed entry points such as ApiProxy read the same service instead of owning parallel provider/model defaults. The plugin config requires `{ provider, model }`. That composition entry is the base of the `agent-default-model` Settings section; a mounted settings provider layers the user's choice over it and changes are visible on the next `currentSelection()` read. `reasoningEffort` belongs to the Settings section but deliberately not to plugin config: a complete saved selection can clear an effort when the next selected model has none, while a composition value would be inherited again. diff --git a/packages/core/agent-default-model/README.zh.md b/packages/core/agent-default-model/README.zh.md index 807b612bd2..00250c28ef 100644 --- a/packages/core/agent-default-model/README.zh.md +++ b/packages/core/agent-default-model/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh run` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。 +该部署默认值供入口在创建尚无会话级模型选择的 Agent 时使用。`AgentDefaultModelService` 提供 `ctx.agentDefaultModel`;`dsh --profile headless` 这类直接入口与 ApiProxy 这类由 Host 支撑的入口读取同一服务,而不是分别持有平行的提供方/模型默认值。 插件配置必须提供 `{ provider, model }`。该组合配置项构成 Settings 中 `agent-default-model` 分节的基础层;挂载的设置提供方在其上叠加用户选择,更改会在下一次调用 `currentSelection()` 时可见。`reasoningEffort` 属于该 Settings 分节,但特意不属于插件配置:完整保存的选择必须能在下一个选定模型没有推理(reasoning)强度时清除旧值,而组合配置值会再次被继承。 diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 0035b0b617..6e3f6c0413 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/core/agent-default-model" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,20 +32,20 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-settings": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/agent-default-model/src/index.ts b/packages/core/agent-default-model/src/index.ts index 4d09b86eb3..5d9e92fc51 100644 --- a/packages/core/agent-default-model/src/index.ts +++ b/packages/core/agent-default-model/src/index.ts @@ -4,13 +4,13 @@ * @module @deepseek-ai/dsh-agent-default-model */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ModelSelection } from '@deepseek-ai/dsh-agent' import { ReasoningEffortId } from '@deepseek-ai/dsh-llm' import { installSettingsSection, settingsNamespace } from '@deepseek-ai/dsh-settings' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Default model selection for Agents created without an explicit model. */ agentDefaultModel: AgentDefaultModelService diff --git a/packages/core/agent-default-model/src/invariant.ts b/packages/core/agent-default-model/src/invariant.ts index 8366018661..48253ae159 100644 --- a/packages/core/agent-default-model/src/invariant.ts +++ b/packages/core/agent-default-model/src/invariant.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-agent-default-model/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-default-model' diff --git a/packages/core/agent-default-model/tests/agent-default-model.spec.ts b/packages/core/agent-default-model/tests/agent-default-model.spec.ts index ef479b4a96..61324d7d71 100644 --- a/packages/core/agent-default-model/tests/agent-default-model.spec.ts +++ b/packages/core/agent-default-model/tests/agent-default-model.spec.ts @@ -1,7 +1,7 @@ /** Default Agent model settings layered over a real settings provider. */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentDefaultModelService, { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '../src/index.ts' import { Settings } from '@deepseek-ai/dsh-settings' import type { SettingsNamespace } from '@deepseek-ai/dsh-settings' diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 68c30d9e55..038822967a 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/core/agent-loop" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -24,18 +31,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -47,6 +54,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/agent-loop/src/agent.ts b/packages/core/agent-loop/src/agent.ts index 6ef965e59e..a2e854f6d4 100644 --- a/packages/core/agent-loop/src/agent.ts +++ b/packages/core/agent-loop/src/agent.ts @@ -31,7 +31,7 @@ import type { EpochHeader, RequestContext, Session, SessionId, TurnEndReason, Us import { canonicalHeader, headerEquals } from '@deepseek-ai/dsh-session' import { joinContextSections, renderContextSections, renderPrompt } from '@deepseek-ai/dsh-system-prompt' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { RuntimeContextProjection } from './runtime-context.ts' import { executeToolCalls } from './tool-calls.ts' diff --git a/packages/core/agent-loop/src/index.ts b/packages/core/agent-loop/src/index.ts index e7c840e296..e492a21073 100644 --- a/packages/core/agent-loop/src/index.ts +++ b/packages/core/agent-loop/src/index.ts @@ -5,9 +5,9 @@ * @module @deepseek-ai/dsh-agent-loop */ -import { Context, FiberState, Service } from 'cordis' +import { Context, FiberState, Service } from '@deepseek-ai/cordis' import { randomUUID } from 'node:crypto' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { emitAgentEvent } from '@deepseek-ai/dsh-agent' import type { Agent, @@ -156,7 +156,7 @@ interface PreparedAgent { dispose(): Promise } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { agentLoop: AgentLoop /** diff --git a/packages/core/agent-loop/src/invariant.ts b/packages/core/agent-loop/src/invariant.ts index d87655d1fc..80fdfba8f9 100644 --- a/packages/core/agent-loop/src/invariant.ts +++ b/packages/core/agent-loop/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-agent-loop/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { isAgentLoopRequest, type GenerateOptions } from '@deepseek-ai/dsh-llm' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { foldRequestHeader } from '@deepseek-ai/dsh-session' diff --git a/packages/core/agent-loop/src/runtime-context.ts b/packages/core/agent-loop/src/runtime-context.ts index 8cf4a41403..63b353ebae 100644 --- a/packages/core/agent-loop/src/runtime-context.ts +++ b/packages/core/agent-loop/src/runtime-context.ts @@ -7,7 +7,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContextSnapshotSection } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import { isReplacementSurfaceEvent } from '@deepseek-ai/dsh-session' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' const SOURCE = '@deepseek-ai/dsh-system-prompt' const CLEARED = 'Current runtime context: none. Earlier runtime-context snapshots no longer apply.' diff --git a/packages/core/agent-loop/src/tool-calls.ts b/packages/core/agent-loop/src/tool-calls.ts index 693ff7bd91..cb34f3dc86 100644 --- a/packages/core/agent-loop/src/tool-calls.ts +++ b/packages/core/agent-loop/src/tool-calls.ts @@ -11,7 +11,7 @@ * @module dsh-agent-loop/tool-calls */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { assertNever, createToolResultMessage, type ToolCallBlock } from '@deepseek-ai/dsh-llm' import type { Session, UserMessage } from '@deepseek-ai/dsh-session' import { TOOL_ABORTED_BEFORE_DISPATCH, TOOL_REGISTRY_SCHEDULER, type ToolExecutionInput, type ToolExecutionMode, type ToolExecutionResult, type ToolRunContext } from '@deepseek-ai/dsh-tools' diff --git a/packages/core/agent-loop/tests/agent-initiator.spec.ts b/packages/core/agent-loop/tests/agent-initiator.spec.ts index 5af6e70fe0..8195ea1df9 100644 --- a/packages/core/agent-loop/tests/agent-initiator.spec.ts +++ b/packages/core/agent-loop/tests/agent-initiator.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context, type Fiber } from 'cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService, { createUserMessage, CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' diff --git a/packages/core/agent-loop/tests/agent.spec.ts b/packages/core/agent-loop/tests/agent.spec.ts index 7ac8a0dd64..e02925ca75 100644 --- a/packages/core/agent-loop/tests/agent.spec.ts +++ b/packages/core/agent-loop/tests/agent.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService from '@deepseek-ai/dsh-llm' diff --git a/packages/core/agent-loop/tests/cancel.spec.ts b/packages/core/agent-loop/tests/cancel.spec.ts index 6fb77d141c..9e53d8067e 100644 --- a/packages/core/agent-loop/tests/cancel.spec.ts +++ b/packages/core/agent-loop/tests/cancel.spec.ts @@ -8,7 +8,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-loop/tests/config-session-id.spec.ts b/packages/core/agent-loop/tests/config-session-id.spec.ts index 74608e5f1a..4ac477d42f 100644 --- a/packages/core/agent-loop/tests/config-session-id.spec.ts +++ b/packages/core/agent-loop/tests/config-session-id.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -113,27 +113,19 @@ describe('config-driven session id', () => { const config = { agents: [{ id: 'main', sessionId: SessionId('config-exact-reload'), provider: 'mock', model: 'mock' }] } const firstLoop = await ctx.plugin(AgentLoop, config) - let first: Agent | undefined - for (let i = 0; i < 50 && first === undefined; i++) { - await new Promise(resolve => setTimeout(resolve, 5)) - first = ctx.agents.get(SessionId('config-exact-reload')) - } - expect(first).toBeDefined() - first!.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })) - await waitForIdle(ctx, first!) + await expect.poll(() => ctx.agents.get(SessionId('config-exact-reload')), { timeout: 5_000 }).toBeDefined() + const first = ctx.agents.get(SessionId('config-exact-reload'))! + first.followup(createUserMessage({ content: [{ type: 'text', text: 'remember me' }], source: { kind: 'user' } })) + await waitForIdle(ctx, first) await firstLoop.dispose() const secondLoop = await ctx.plugin(AgentLoop, config) - let second: Agent | undefined - for (let i = 0; i < 50 && second === undefined; i++) { - await new Promise(resolve => setTimeout(resolve, 5)) - second = ctx.agents.get(SessionId('config-exact-reload')) - } - expect(second).toBeDefined() - expect(JSON.stringify(second!.session.deriveMessages())).toContain('remember me') - second!.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) - await waitForIdle(ctx, second!) - await ctx.sessions.flush(second!.session) + await expect.poll(() => ctx.agents.get(SessionId('config-exact-reload')), { timeout: 5_000 }).toBeDefined() + const second = ctx.agents.get(SessionId('config-exact-reload'))! + expect(JSON.stringify(second.session.deriveMessages())).toContain('remember me') + second.followup(createUserMessage({ content: [{ type: 'text', text: 'continue' }], source: { kind: 'user' } })) + await waitForIdle(ctx, second) + await ctx.sessions.flush(second.session) const loaded = await ctx.sessionPersistence.load(SessionId('config-exact-reload')) expect(loaded.events.filter(event => event.type === 'turn/start')).toHaveLength(2) @@ -423,18 +415,14 @@ describe('config-driven session id', () => { await ctx2.plugin(SessionPersistenceJsonl, { root }) ctx2.llm.registerAdapter(['mock'], new MockAdapter([textResponse('second')])) - // The deferred resume runs on a microtask after the backend is available. - let resumed: Agent | undefined - for (let i = 0; i < 50 && !resumed; i++) { - await new Promise(r => setTimeout(r, 5)) - resumed = ctx2.agents.get(SessionId('sticky-1')) - } - expect(resumed).toBeDefined() + // The deferred resume runs after the backend is available. + await expect.poll(() => ctx2.agents.get(SessionId('sticky-1')), { timeout: 5_000 }).toBeDefined() + const resumed = ctx2.agents.get(SessionId('sticky-1'))! // The live session id IS the resumed id (NOT a fresh ${id}-session-), // and the prior turn's user message is in the derived history. - expect(resumed!.id).toBe(SessionId('sticky-1')) - expect(resumed!.session.id).toBe('sticky-1') - const derived = resumed!.session.deriveMessages() + expect(resumed.id).toBe(SessionId('sticky-1')) + expect(resumed.session.id).toBe('sticky-1') + const derived = resumed.session.deriveMessages() expect(JSON.stringify(derived)).toContain('remember me') await ctx2.fiber.dispose() }) diff --git a/packages/core/agent-loop/tests/contract-regressions.spec.ts b/packages/core/agent-loop/tests/contract-regressions.spec.ts index 7dfbaddc41..50f6257f45 100644 --- a/packages/core/agent-loop/tests/contract-regressions.spec.ts +++ b/packages/core/agent-loop/tests/contract-regressions.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, LlmError, MessageSource, ProviderRequestId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionEvent, SessionId, TurnEndReason, type UserMessage } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-loop/tests/coverage-edges.spec.ts b/packages/core/agent-loop/tests/coverage-edges.spec.ts index 617c305071..2ebe1c0884 100644 --- a/packages/core/agent-loop/tests/coverage-edges.spec.ts +++ b/packages/core/agent-loop/tests/coverage-edges.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, LlmError, StreamChunk, errorChain } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/core/agent-loop/tests/interception.spec.ts b/packages/core/agent-loop/tests/interception.spec.ts index 646c6d41fa..01cf57237b 100644 --- a/packages/core/agent-loop/tests/interception.spec.ts +++ b/packages/core/agent-loop/tests/interception.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, diff --git a/packages/core/agent-loop/tests/invariant.spec.ts b/packages/core/agent-loop/tests/invariant.spec.ts index d77ad3a7d6..4295eac226 100644 --- a/packages/core/agent-loop/tests/invariant.spec.ts +++ b/packages/core/agent-loop/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import InvariantService from '@deepseek-ai/dsh-invariants' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' diff --git a/packages/core/agent-loop/tests/loop.spec.ts b/packages/core/agent-loop/tests/loop.spec.ts index c42331de58..d87fb56486 100644 --- a/packages/core/agent-loop/tests/loop.spec.ts +++ b/packages/core/agent-loop/tests/loop.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, LlmError, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TurnEndReason } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-loop/tests/properties.spec.ts b/packages/core/agent-loop/tests/properties.spec.ts index 0add31bd1f..7fcba03ebf 100644 --- a/packages/core/agent-loop/tests/properties.spec.ts +++ b/packages/core/agent-loop/tests/properties.spec.ts @@ -10,7 +10,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/core/agent-loop/tests/request-cache.e2e.ts b/packages/core/agent-loop/tests/request-cache.e2e.ts index 0c7c65e483..0e0da3cb81 100644 --- a/packages/core/agent-loop/tests/request-cache.e2e.ts +++ b/packages/core/agent-loop/tests/request-cache.e2e.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-loop/tests/request-error.spec.ts b/packages/core/agent-loop/tests/request-error.spec.ts index d143bd79ae..eae579a8cb 100644 --- a/packages/core/agent-loop/tests/request-error.spec.ts +++ b/packages/core/agent-loop/tests/request-error.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService, { createUserMessage, LlmError } from '@deepseek-ai/dsh-llm' diff --git a/packages/core/agent-loop/tests/request-reconstruction.spec.ts b/packages/core/agent-loop/tests/request-reconstruction.spec.ts index 62a7ae5e71..dc69e1b835 100644 --- a/packages/core/agent-loop/tests/request-reconstruction.spec.ts +++ b/packages/core/agent-loop/tests/request-reconstruction.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, LlmError, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmModelReasoningInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' diff --git a/packages/core/agent-loop/tests/resume.spec.ts b/packages/core/agent-loop/tests/resume.spec.ts index 964ef82aa6..e32db7c846 100644 --- a/packages/core/agent-loop/tests/resume.spec.ts +++ b/packages/core/agent-loop/tests/resume.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/core/agent-loop/tests/runtime-context.spec.ts b/packages/core/agent-loop/tests/runtime-context.spec.ts index 463515a61b..9cf76e79ba 100644 --- a/packages/core/agent-loop/tests/runtime-context.spec.ts +++ b/packages/core/agent-loop/tests/runtime-context.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { RuntimeContextProjection } from '../src/runtime-context.ts' diff --git a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts index 3f3e0a43d8..898604ff1b 100644 --- a/packages/core/agent-loop/tests/scope-lifecycle.spec.ts +++ b/packages/core/agent-loop/tests/scope-lifecycle.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context, symbols, type EffectMeta, type Fiber } from 'cordis' +import { Context, symbols, type EffectMeta, type Fiber } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-loop/tests/tool-calls.spec.ts b/packages/core/agent-loop/tests/tool-calls.spec.ts index 89b2e300bd..972c321138 100644 --- a/packages/core/agent-loop/tests/tool-calls.spec.ts +++ b/packages/core/agent-loop/tests/tool-calls.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-loop/tests/tool-order.spec.ts b/packages/core/agent-loop/tests/tool-order.spec.ts index 9fa321697a..195b1ad76a 100644 --- a/packages/core/agent-loop/tests/tool-order.spec.ts +++ b/packages/core/agent-loop/tests/tool-order.spec.ts @@ -8,7 +8,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, foldRequestHeader } from '@deepseek-ai/dsh-session' import SystemPrompt, { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent-tool-mode/package.json b/packages/core/agent-tool-mode/package.json index 236c9e5891..e0d35abf0f 100644 --- a/packages/core/agent-tool-mode/package.json +++ b/packages/core/agent-tool-mode/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-agent-tool-mode", "description": "Agent-plane presentation selector: composes one agent's tools as Code Mode, native, or both", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/core/agent-tool-mode" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,12 +32,12 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -40,6 +47,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/agent-tool-mode/src/index.ts b/packages/core/agent-tool-mode/src/index.ts index d2f1e8fd49..eded1c34c5 100644 --- a/packages/core/agent-tool-mode/src/index.ts +++ b/packages/core/agent-tool-mode/src/index.ts @@ -5,8 +5,10 @@ * The tool registry itself stays on the host plane — the agent loop's * scheduler, the API proxy's presenters, and every tool plugin are all its * consumers, so it cannot move into a preset. What a preset CAN own is the - * presentation: `ctx.tools.presentAs()` declares it for the mounting agent - * alone, so a Code Mode agent runs beside native ones in one process. + * presentation: `ctx.tools.presentAs()` declares it for the mounting SCOPE, + * which is the preset's standing mount, so the declaration covers every agent + * joined to that preset and a Code Mode preset runs beside native ones in one + * process. One row per composition, not one per session. * * A code mode needs a TypeScript code runtime, which is a host-plane service * ([`dsh-code-runtime-worker`](../../code-runtime/code-runtime-worker/README.md)). @@ -16,8 +18,8 @@ * @module @deepseek-ai/dsh-agent-tool-mode */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ToolPresentationMode } from '@deepseek-ai/dsh-tools' // Type-only: brings the `ctx.tools` Context merge into this program. import type {} from '@deepseek-ai/dsh-tools' @@ -50,8 +52,8 @@ export const Config: z = z.object({ }) /** - * Declare this agent's tool presentation. - * @param ctx - the mounting agent's scope context. + * Declare the tool presentation for every agent this composition covers. + * @param ctx - the mounting composition's scope context (a preset's standing scope). * @param config - the selected presentation. */ export function apply(ctx: Context, config: Config): void { diff --git a/packages/core/agent-tool-mode/src/invariant.ts b/packages/core/agent-tool-mode/src/invariant.ts index bd576cb943..a7fbd8b923 100644 --- a/packages/core/agent-tool-mode/src/invariant.ts +++ b/packages/core/agent-tool-mode/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-tool-mode' diff --git a/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts b/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts index ba9b9972ff..b626025dfd 100644 --- a/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts +++ b/packages/core/agent-tool-mode/tests/agent-tool-mode.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createScope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 9f64d33e75..6c85552224 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/core/agent" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -30,13 +37,13 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-type-meta": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -46,6 +53,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/agent/src/dispatch.ts b/packages/core/agent/src/dispatch.ts index 7eff09b01d..f95582851d 100644 --- a/packages/core/agent/src/dispatch.ts +++ b/packages/core/agent/src/dispatch.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-agent/dispatch */ -import type { Context, Events } from 'cordis' +import type { Context, Events } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { AssembleContext } from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/agent/src/index.ts b/packages/core/agent/src/index.ts index 0f2fcabf3a..dffc1f01b3 100644 --- a/packages/core/agent/src/index.ts +++ b/packages/core/agent/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-agent */ -import { Context, FiberState, getTraceable, Service, symbols } from 'cordis' -import type { Fiber } from 'cordis' +import { Context, FiberState, getTraceable, Service, symbols } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' import { AsyncLocalStorage } from 'node:async_hooks' import { isPromise } from 'node:util/types' import { scopeTarget } from '@deepseek-ai/dsh-scope' @@ -32,7 +32,7 @@ declare module '@deepseek-ai/dsh-type-meta' { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { agents: AgentRegistry /** diff --git a/packages/core/agent/src/invariant.ts b/packages/core/agent/src/invariant.ts index a561e862cb..fc3ebb2599 100644 --- a/packages/core/agent/src/invariant.ts +++ b/packages/core/agent/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned agent lifecycle invariants. @module @deepseek-ai/dsh-agent/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' diff --git a/packages/core/agent/src/model-selection.ts b/packages/core/agent/src/model-selection.ts index a49e2f5979..2cb7e4468f 100644 --- a/packages/core/agent/src/model-selection.ts +++ b/packages/core/agent/src/model-selection.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-agent/model-selection */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { LlmCallConfig, ReasoningEffortId } from '@deepseek-ai/dsh-llm' /** Complete provider, model, and optional reasoning effort selected for one live Agent. */ diff --git a/packages/core/agent/src/runtime-types.ts b/packages/core/agent/src/runtime-types.ts index 3698c05018..7d713f8c77 100644 --- a/packages/core/agent/src/runtime-types.ts +++ b/packages/core/agent/src/runtime-types.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-agent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Scoped } from '@deepseek-ai/dsh-scope' import type { LlmCallConfig, LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { AgentCancelCause, Session, SessionId, UserMessage } from '@deepseek-ai/dsh-session' @@ -143,7 +143,7 @@ export interface Agent { inject(message: UserMessage): void } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { // ---- lifecycle (emit) ---- /** diff --git a/packages/core/agent/tests/agent-initiator.spec.ts b/packages/core/agent/tests/agent-initiator.spec.ts index 7e0b70d13c..c86e0edfcf 100644 --- a/packages/core/agent/tests/agent-initiator.spec.ts +++ b/packages/core/agent/tests/agent-initiator.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { runInNewContext } from 'node:vm' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/core/agent/tests/agent.spec.ts b/packages/core/agent/tests/agent.spec.ts index 643a3a49a6..d5c345593c 100644 --- a/packages/core/agent/tests/agent.spec.ts +++ b/packages/core/agent/tests/agent.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context, Service, symbols } from 'cordis' +import { Context, Service, symbols } from '@deepseek-ai/cordis' import { createUserMessage, freezeMessage } from '@deepseek-ai/dsh-llm' import { Session, SessionId, type UserMessage } from '@deepseek-ai/dsh-session' import AgentRegistry, { diff --git a/packages/core/agent/tests/invariant.spec.ts b/packages/core/agent/tests/invariant.spec.ts index 458a10714d..26bc486b2e 100644 --- a/packages/core/agent/tests/invariant.spec.ts +++ b/packages/core/agent/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import { scopeTarget } from '@deepseek-ai/dsh-scope' diff --git a/packages/core/agent/tests/model-selection.spec.ts b/packages/core/agent/tests/model-selection.spec.ts index 2d7a1a7336..3e61060cd6 100644 --- a/packages/core/agent/tests/model-selection.spec.ts +++ b/packages/core/agent/tests/model-selection.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import { agentEvents, diff --git a/packages/core/agent/tests/verify-export-jsdoc.spec.ts b/packages/core/agent/tests/verify-export-jsdoc.spec.ts index cc361a956e..cffa4e8564 100644 --- a/packages/core/agent/tests/verify-export-jsdoc.spec.ts +++ b/packages/core/agent/tests/verify-export-jsdoc.spec.ts @@ -154,7 +154,7 @@ describe('verify-export-jsdoc type-level exports', () => { it('skips `declare module` augmentation bodies (the cordis gate owns them)', () => { expect(collectExportJsdocViolations(make( - "declare module 'cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n", + "declare module '@deepseek-ai/cordis' {\n interface Events {\n 'fix/x'(): void\n }\n}\nexport {}\n", ))).toEqual([]) }) }) diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index a752ee4c77..d0a67e8841 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/core/scope" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/scope/src/index.ts b/packages/core/scope/src/index.ts index b5f58dbdf0..84df79510e 100644 --- a/packages/core/scope/src/index.ts +++ b/packages/core/scope/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-scope */ -import type { Context, Fiber } from 'cordis' -import { Context as CordisContext } from 'cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' +import { Context as CordisContext } from '@deepseek-ai/cordis' export { AnonymousEntries, NamedEntries, ScopedLayers } from './store.ts' export type { ScopeLayer } from './store.ts' diff --git a/packages/core/scope/src/invariant.ts b/packages/core/scope/src/invariant.ts index a5bd59f263..b478b9417d 100644 --- a/packages/core/scope/src/invariant.ts +++ b/packages/core/scope/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned scoped-dispatch invariants. @module @deepseek-ai/dsh-scope/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { carrierKeyOf, isScopeCarrier } from '@deepseek-ai/dsh-scope' import { scopedSubjectResolverFor } from './scoped-events.generated.ts' diff --git a/packages/core/scope/src/store.ts b/packages/core/scope/src/store.ts index a9e1468ccd..3b40693c82 100644 --- a/packages/core/scope/src/store.ts +++ b/packages/core/scope/src/store.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-scope */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { scopeChainOf, scopeOf } from './index.ts' import type { ScopeKey } from './index.ts' diff --git a/packages/core/scope/tests/invariant.spec.ts b/packages/core/scope/tests/invariant.spec.ts index 8744bb9aa7..5e8ba13a3e 100644 --- a/packages/core/scope/tests/invariant.spec.ts +++ b/packages/core/scope/tests/invariant.spec.ts @@ -1,7 +1,7 @@ import { freezeMessage, MessageId } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import type { Events } from 'cordis' +import { Context } from '@deepseek-ai/cordis' +import type { Events } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { scopeTarget } from '@deepseek-ai/dsh-scope' import * as ScopeInvariant from '@deepseek-ai/dsh-scope/invariant' diff --git a/packages/core/scope/tests/scope.spec.ts b/packages/core/scope/tests/scope.spec.ts index 7007624d53..bb0361fbf2 100644 --- a/packages/core/scope/tests/scope.spec.ts +++ b/packages/core/scope/tests/scope.spec.ts @@ -1,9 +1,9 @@ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { bindScopeParent, carrierKeyOf, createScope, isScopeCarrier, scopeChainOf, scopeOf, scopeParentOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scope, Scoped } from '@deepseek-ai/dsh-scope' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { /** * Test-only event for scope-filtered dispatch. diff --git a/packages/core/scope/tests/store.spec.ts b/packages/core/scope/tests/store.spec.ts index 622dbeb541..025f0f7181 100644 --- a/packages/core/scope/tests/store.spec.ts +++ b/packages/core/scope/tests/store.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { AnonymousEntries, createScope, diff --git a/packages/core/session/README.i18n.yaml b/packages/core/session/README.i18n.yaml index c5ed6a2c98..72d20a2818 100644 --- a/packages/core/session/README.i18n.yaml +++ b/packages/core/session/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/core/session/README.md -README.md: db477d94037d3463870fc8e66ea35d5e607fb6fe -README.zh.md: 1ce1e823a7e0fdbcf7b6898764a89c52b74adf6a +README.md: 57569e9c0dbfa7cb696e3a561a9ff108c2ac981f +README.zh.md: 16629dc70c79ca838ba7088aeafcc5b38b124f87 diff --git a/packages/core/session/README.md b/packages/core/session/README.md index db477d9403..57569e9c0d 100644 --- a/packages/core/session/README.md +++ b/packages/core/session/README.md @@ -76,10 +76,11 @@ Also defines `TurnEndReasonMap`, the merge-extensible `kind`-tagged sum type for An interrupted live turn ends with `{ kind: 'aborted', reason: AgentCancelCause }`, preserving the typed cancellation cause in the durable transcript. Persistence imports the coarse aborted outcome from the supported older format as `{ kind: 'aborted', reason: { kind: 'legacy' } }`, because that record did not retain its caller. A turn failure carries `{ kind: 'error', error }`; crash recovery alone synthesizes `{ kind: 'interrupted' }`. -Every `SessionEvent` carries two optional top-level fields (structural metadata): +Every `SessionEvent` carries three optional top-level fields (structural metadata): - `sourceEventSeqs?: number[]` — seq numbers of earlier events cited as sources (e.g., the `assistant/chunk` seqs behind an `assistant/message`, or the shadowed entries behind a compaction replacement entry). On `assistant/message`, a present `[]` records a known empty provider stream, while omission means a legacy or foreign event did not record the source stream; other surface events require a non-empty list when this field is present. - `surfaceOp?: SurfaceOp` — how this event entered the surface. Absent for non-surface events (boundaries, chunks, usage, errors). +- `ignorable?: true` — marks an event a reader may safely skip when it does not recognize the type; absent means required, so an unknown-type event refuses session reconstruction ([mechanism](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)). ### Metadata types (`types.ts`) @@ -139,5 +140,5 @@ Logging causes no invalidation, and exact reconstruction preserves request-prefi - **Session branching/tree** (pi-style entry tree) — deferred unless needed beyond boundary-based `fork()`. - **`fork()` cuts only at stable boundaries of live sessions** — the selected prefix must end outside an open turn and the source must be in the store; forking a persisted-but-unloaded session is excluded from the [fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md). -- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes and a backend rejects any other version. Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)). +- **`SESSION_FORMAT_VERSION` stays pinned at `0`** — pre-release, no broad compatibility implied: `Session` accepts only current seed shapes, and a backend refuses any other version naming the direction (newer: "written by a newer harness — upgrade"; older: no upgrade path ships yet). Unknown event types refuse the same way unless marked `ignorable` in the envelope; the versioning mechanism is the [session-log-version-mechanism note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md). Narrow storage import upgrades belong to the persistence boundary ([policy](../../../AGENTS.md), [pre-identity message recovery](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md)). - **`TurnEndReasonMap` omits the ACP-named `refusal` / `max_turn_requests` variants** — producer-gated: they land when an adapter or the loop first emits them. diff --git a/packages/core/session/README.zh.md b/packages/core/session/README.zh.md index 1ce1e823a7..16629dc70c 100644 --- a/packages/core/session/README.zh.md +++ b/packages/core/session/README.zh.md @@ -76,10 +76,11 @@ 被中断的实时轮次以 `{ kind: 'aborted', reason: AgentCancelCause }` 结束,在持久 transcript(文本记录)中保留类型化取消原因。持久化会将受支持旧格式中的粗粒度中止结果导入为 `{ kind: 'aborted', reason: { kind: 'legacy' } }`,因为该记录没有保留调用方。轮次失败携带 `{ kind: 'error', error }`;只有崩溃恢复会合成 `{ kind: 'interrupted' }`。 -每个 `SessionEvent` 都有两个可选顶层字段(结构元数据): +每个 `SessionEvent` 都有三个可选顶层字段(结构元数据): - `sourceEventSeqs?: number[]`:被引用为来源的较早事件 seq(例如 `assistant/message` 引用的 `assistant/chunk` seq,或压缩替换条目引用的已遮蔽条目)。对于 `assistant/message`,存在的 `[]` 表示已知提供方流为空;省略则表示旧版或外部事件没有记录源流。其他 surface 事件若有此字段,则要求非空列表。 - `surfaceOp?: SurfaceOp`:事件进入 surface 的方式。非 surface 事件(边界、分片、用量、错误)不含该字段。 +- `ignorable?: true`:标记读取器在不认识事件类型时可以安全跳过该事件;缺失表示必需,不认识的事件类型会使会话重建被拒绝([机制](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md))。 ### 元数据类型(`types.ts`) @@ -139,5 +140,5 @@ - **会话分支/树**(pi 风格条目树):除非需要超越基于边界的 `fork()` 能力,否则暂缓。 - **`fork()` 仅在实时会话的稳定边界处切分**:所选前缀结束时不得有开放轮次,且源会话必须位于存储中;[fork API](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) 不支持对已持久化但未加载的会话进行 fork。 -- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端会拒绝其他任何版本。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。 +- **`SESSION_FORMAT_VERSION` 固定为 `0`**:预发布阶段不承诺广泛兼容性;`Session` 只接受当前 seed 形状,后端拒绝其他任何版本并说明方向(更新的版本提示"由更新的 harness 写入,请升级";更旧的版本说明尚无升级路径)。不认识的事件类型同样被拒绝,除非信封带 `ignorable` 标记;版本机制见 [session-log 版本机制 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md)。范围受限的存储导入升级应由持久化边界负责([政策](../../../AGENTS.md)、[消息标识机制引入前的消息恢复](../../../.agents/notes/implemented/bug-fix/2026-07-28-load-pre-identity-session-messages.md))。 - **`TurnEndReasonMap` 不含 ACP(Agent Client Protocol)命名的 `refusal`/`max_turn_requests` 变体**:受生产方约束;只有当适配器或循环首次产生这些变体时才加入。 diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 04aa221573..3528334b3c 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/core/session" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -34,12 +41,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-type-meta": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -48,6 +55,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/session/src/index.ts b/packages/core/session/src/index.ts index f27b2f4622..5251ca9408 100644 --- a/packages/core/session/src/index.ts +++ b/packages/core/session/src/index.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-session */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { isAbsolute } from 'node:path' import { deepFreeze } from '@deepseek-ai/dsh-llm' import { scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' @@ -32,6 +32,7 @@ export type { ChunkRow, StorageRecord } from './chunk-rows.ts' export type { SessionSurface, SurfaceFoldReplacement, SurfaceFoldResult } from './surface.ts' export { deriveEventMessage, foldSurface, isAppendSurfaceEvent, isReplacementSurfaceEvent, isSurfaceEvent, isSurfaceEligibleType } from './surface.ts' export { canonicalHeader, foldRequestHeader, headerEquals } from './request-header.ts' +export { KNOWN_SESSION_EVENT_TYPES } from './known-event-types.ts' /** * Find the latest closed turn that entered at least one model step, ignoring @@ -54,7 +55,7 @@ export function findLastMessageTurnEnd( return latest } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessions: SessionStore } @@ -243,6 +244,7 @@ function assertSessionEventEnvelope(value: Record, index: numbe case 'data': case 'surfaceOp': case 'sourceEventSeqs': + case 'ignorable': break default: throw new Error(`seed event at index ${index} has an invalid event envelope`) @@ -254,7 +256,8 @@ function assertSessionEventEnvelope(value: Record, index: numbe if (typeof type !== 'string' || typeof seq !== 'number' || !Number.isSafeInteger(seq) || seq < 0 || typeof time !== 'number' || !Number.isSafeInteger(time) - || event['data'] === undefined) { + || event['data'] === undefined + || (event['ignorable'] !== undefined && event['ignorable'] !== true)) { throw new Error(`seed event at index ${index} has an invalid event envelope`) } switch (type) { diff --git a/packages/core/session/src/invariant.ts b/packages/core/session/src/invariant.ts index 77d2b22ac2..da7cd55964 100644 --- a/packages/core/session/src/invariant.ts +++ b/packages/core/session/src/invariant.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-session/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { assertNever } from '@deepseek-ai/dsh-llm' import type { CallId } from '@deepseek-ai/dsh-llm' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/core/session/src/known-event-types.ts b/packages/core/session/src/known-event-types.ts new file mode 100644 index 0000000000..2c2b5487bb --- /dev/null +++ b/packages/core/session/src/known-event-types.ts @@ -0,0 +1,59 @@ +/** + * GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run + * `pnpm run gen-persistence-catalog` to regenerate (verified fresh by + * `pnpm run verify-persistence-catalog`, part of `doc-sync`). + * @module @deepseek-ai/dsh-session/known-event-types + */ + +/** + * Every `SessionEventMap` member declared in this repository — the event + * vocabulary this build understands. The persistence read path refuses to + * interpret a log containing a type outside this set unless the event + * carries the envelope's `ignorable` marker (see `SessionEvent.ignorable` + * in `./types.ts`): such a log was likely written by a newer harness, and + * silently skipping a required event would reconstruct a wrong session. + * Downstream (out-of-repo) plugin events are outside this list by + * construction; a registration surface for them is deferred until such a + * consumer exists. + */ +export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([ + 'agent-preset/selected', + 'agent/inbox/spliced', + 'approval/asked', + 'approval/decided', + 'approval/policy', + 'assistant/chunk', + 'assistant/message', + 'command/done', + 'command/run', + 'compact/end', + 'compact/prune', + 'compact/start', + 'compact/summary', + 'feedback/record', + 'goal/change', + 'hook/invoked', + 'hook/result', + 'llm/retry', + 'llm/retry-started', + 'permission/preset', + 'plan/mode', + 'request/context', + 'request/header', + 'sandbox/mode', + 'session/end-seed', + 'session/title', + 'session/title-llm-request', + 'step/end', + 'step/start', + 'subagent/descriptor', + 'todo/write', + 'tool/call', + 'tool/code-dispatch', + 'tool/code-dispatch-start', + 'tool/result', + 'turn/end', + 'turn/start', + 'user/message', + 'web/deepseek-search-llm-request', +]) diff --git a/packages/core/session/src/types.ts b/packages/core/session/src/types.ts index 35dd9d1dab..9e50c18d11 100644 --- a/packages/core/session/src/types.ts +++ b/packages/core/session/src/types.ts @@ -30,8 +30,23 @@ export function SessionId(id: string): SessionId { * and enforced by every persistence backend on load. The single source of truth for the * version — write sites and the load-time check all read it. * While the harness is unreleased it is pinned at `0`: no compatibility is - * implied, incompatible logs are rejected, and no migration is provided. A - * monotonic version policy starts with the first tagged release. + * implied, incompatible logs are rejected, and no migration is provided. + * + * The version is a single monotonic integer with no major/minor split. Whether + * a bump is needed is decided by what the WRITER emits, never by what a newer + * reader can accept: bump exactly when an older runtime could no longer handle + * a new log with full semantic correctness ("parses without error" is not + * correctness — silently skipping content that shapes reconstruction is a + * wrong read). Only structural changes reach that bar: the header shape, the + * {@link SessionEvent} envelope, core event semantics, or the surface + * mechanism (the {@link SurfaceEventType} set and {@link SurfaceOp} variants). + * Adding an ordinary event type does not bump — the per-event + * {@link SessionEvent.ignorable} guard covers vocabulary growth instead. When + * in doubt, bump: a near-identity upgrade step is almost free, a missed bump + * makes older runtimes read new logs wrong silently. The full mechanism + * (upgrade-step chain, in-memory view conversion, migrate-on-continue) is + * recorded in the session-log-version-mechanism Agent Note + * (`.agents/notes/implemented/architecture/2026-08-10-session-log-version-mechanism.md`). */ export const SESSION_FORMAT_VERSION = 0 @@ -389,6 +404,17 @@ export type SessionEvent = { /** Unix epoch milliseconds. */ time: number data: SessionEventMap[K] + /** + * Marks an event a reader may safely skip when it does not recognize + * `type`. Absent means required: a reader meeting an unrecognized type + * without this marker MUST refuse to reconstruct the session instead of + * silently dropping the event, because an unrecognized required event may + * change how the rest of the log is interpreted. A writer sets `true` only + * on purely informational records whose loss cannot affect reconstruction; + * defaulting to required means a forgotten marker over-refuses (an + * inconvenience) rather than silently resuming a gutted session. + */ + ignorable?: true } & (K extends SurfaceEventType ? { /** * Seq numbers of earlier events that this event cites as sources diff --git a/packages/core/session/tests/fork.spec.ts b/packages/core/session/tests/fork.spec.ts index 58985cfb34..35b0cf3a54 100644 --- a/packages/core/session/tests/fork.spec.ts +++ b/packages/core/session/tests/fork.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId , createMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionForkError, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, TurnEndReason } from '@deepseek-ai/dsh-session' diff --git a/packages/core/session/tests/invariant.spec.ts b/packages/core/session/tests/invariant.spec.ts index cc5e21f57a..5d6fff4cef 100644 --- a/packages/core/session/tests/invariant.spec.ts +++ b/packages/core/session/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createScope, scopeTarget } from '@deepseek-ai/dsh-scope' import { createUserMessage, CallId, createMessage, createToolResultMessage, freezeMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, TOOL_NOT_STARTED } from '@deepseek-ai/dsh-session' diff --git a/packages/core/session/tests/scoped.spec.ts b/packages/core/session/tests/scoped.spec.ts index 441d2d8029..7d75302248 100644 --- a/packages/core/session/tests/scoped.spec.ts +++ b/packages/core/session/tests/scoped.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/core/session/tests/session.spec.ts b/packages/core/session/tests/session.spec.ts index b0302b9d4d..7fe7ee01c7 100644 --- a/packages/core/session/tests/session.spec.ts +++ b/packages/core/session/tests/session.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, createMessage, createToolResultMessage, MessageId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import SessionStore, { adoptSessionEvent, @@ -1090,12 +1090,20 @@ describe('Session', () => { { ...base, time: '1' }, { ...base, time: 0.5 }, { type: base.type, seq: base.seq, time: base.time }, + { ...base, ignorable: false }, + { ...base, ignorable: 'yes' }, ] for (const [index, event] of cases.entries()) { expect(() => Session.create(SessionId(`bad-envelope-${index}`), [event as SessionEvent])) .toThrow(/invalid event envelope/) } + + // `ignorable: true` is the one accepted marker value (unknown-type skip contract). + const marked = Session.create(SessionId('ignorable-envelope'), [ + { ...base, ignorable: true } as SessionEvent, + ]) + expect(marked.events[0]?.ignorable).toBe(true) }) }) diff --git a/packages/core/session/tests/typert.spec.ts b/packages/core/session/tests/typert.spec.ts index e1e2b32d68..2ea8e4572e 100644 --- a/packages/core/session/tests/typert.spec.ts +++ b/packages/core/session/tests/typert.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' diff --git a/packages/core/system-prompt/README.i18n.yaml b/packages/core/system-prompt/README.i18n.yaml index 7d4e8f07bb..b1f068fa39 100644 --- a/packages/core/system-prompt/README.i18n.yaml +++ b/packages/core/system-prompt/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/core/system-prompt/README.md -README.md: 13b05bfcd19212ade42f22ece455871d022e6260 -README.zh.md: 0f9e7a2358134018975db1bc3c6b7206a274b3ec +README.md: cedda783d549633f5be9765a9a074e968d99500d +README.zh.md: 41729cdd1cfe6ebbd86f38c15bab5c50bd6ff7d2 diff --git a/packages/core/system-prompt/README.md b/packages/core/system-prompt/README.md index 13b05bfcd1..cedda783d5 100644 --- a/packages/core/system-prompt/README.md +++ b/packages/core/system-prompt/README.md @@ -16,19 +16,19 @@ System prompt assembly registry. Plugins contribute ordered sections, tool schem ### Public API -- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. +- `ctx.systemPrompt.section(section: PromptSection): () => void` Contribute a section. The layer is the calling context's scope: `agent.ctx` contributes to that agent alone, shadowing a same-named global section there. A `complete: true` section becomes the exact complete prompt after the assembly waterfall; more than one effective complete section rejects assembly. Duplicate names within one layer and non-finite orders throw. Disposed with the calling fiber. - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void` Contribute tool schemas, evaluated at each assembly with that assembly's context. `ToolProviderResult` = `{ schemas, knownNames? }`: `schemas` is the post-restriction visible set; `knownNames` is the pre-restriction universe used by `toolOrder`. A provider must not return a schema named `TOOL_ORDER_REST`. Scoped providers are consulted only for their scope's assemblies. Disposed with the calling fiber. - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void` Contribute a prompt variable, referenced from section text as `{{name}}`. Scoped variables shadow a same-named global for that agent. Duplicate-in-layer or unreferenceable names throw; `undefined` means "no value for this assembly". Disposed with the calling fiber. -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform waterfall. Runs through the scope-filtered `system-prompt/assemble` waterfall and returns its authoritative result. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise` Assemble the prompt for one caller: the global layer merged with `context.scope`'s layer, with tool schemas detached before the transform waterfall. Runs through the scope-filtered `system-prompt/assemble` waterfall, then restores an effective complete section as the sole prompt section. An optional `context.signal` explicitly controls this assembly request; providers and listeners may cooperate with it but must not retain it for another turn. Rejects for multiple complete sections, when a configured `toolOrder` names a tool outside the providers' `knownNames` universe, or when a provider returns the reserved rest-entry name. ### Live events -`system-prompt/assemble` is authoritative; listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated region of [system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) owns signatures and dispatch contracts. +`system-prompt/assemble` is authoritative for ordinary sections; a complete section is the final prompt constraint applied after the waterfall. Listeners that replace entries must preserve any active Code Mode or structured-output protocol. Use [`ToolRegistry.restrict()`](../tools/README.md) when filtering must stay aligned across presentation, lookup, and execution. Registry-change notifications are unfiltered. The generated region of [system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) owns signatures and dispatch contracts. ### Key types - `AssembleContext` — what one `assemble()` call is FOR. Merge-extensible; declares `scope?: ScopeKey` (the layer selector) and `signal?: AbortSignal` (the explicit request control capability) here, while `dsh-agent` declares `agent?: Agent` (the typed DX field — never set without `scope`; use `assembleContextFor(agent, signal)`). Providers must tolerate absent fields because a bare `assemble()` carries an empty, scope-less, signal-less context. `signal` is a request value, not part of the ambient Agent execution frame. -- `PromptSection` — `{ name, order, text }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. +- `PromptSection` — `{ name, order, text, complete? }`. Sections are concatenated in ascending `order`. Order bands: `-100` is the harness identity, `0` the deployment persona, tool guidance uses `100–199`. One effective `complete` section suppresses all other sections after cooperative assembly. - `PromptAssembly` — `{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`. Section texts arrive resolved but not yet interpolated; `variables` holds every registered variable resolved against the context. Tool schemas are part of the assembly by design: "what the model is told it can do" is one coherent thing, even though adapters transmit schemas as a separate wire field. - `renderPrompt(assembly)` — interpolates `{{variable}}` references in each section, drops empty sections, joins with blank lines. STRICT: an unknown reference (`Object.hasOwn` lookup — prototype names like `{{constructor}}` are unknown), a registered-but-valueless reference, a malformed complete `{{…}}` group, or a `{{` that opens no complete group while a `}}` still follows (`{{{model}}}`) throws — fail loud beats shipping a malformed prompt. A lone `{{` with no `}}` anywhere after it passes through verbatim; substituted values are never re-scanned. @@ -39,7 +39,7 @@ Merge-extensible: plugins can declare extra fields on `PromptAssembly` and `Asse - Section providers: tool packages own their cross-call guidance (`tool:bash`, `tool:read`, …); this plugin owns `harness:identity` and `deployment:persona`. - Variable providers: the agent loop registers `model` and `cwd`; any plugin can register the facts it owns (a future `date`, git state, …). - Tool schema providers: `ToolRegistry` registers itself as a tool provider automatically. -- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller. +- The [`system-prompt/assemble` waterfall](#live-events): cooperatively mutate or replace the assembly per caller before any complete-section constraint is enforced. Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md). @@ -49,7 +49,7 @@ Design rationale: [the prompt-variables Agent Note](../../../.agents/notes/imple #### What the model sees -By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener for a deployment that owns the complete compatibility persona. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The final `system-prompt/assemble` waterfall result is authoritative, so an expert listener's changes determine the delivered prompt and tool schemas. +By default every assembly starts with the harness identity below, then the configured persona and ordered plugin sections after strict variable interpolation. `includeHarnessIdentity: false` omits only that fixed opener. Empty sections disappear; scoped sections and variables can shadow globals for one agent. The `system-prompt/assemble` waterfall determines the delivered prompt and tool schemas unless one effective section declares itself complete; that exact section then becomes the whole system prompt while the waterfall's contexts, tools, and variables remain. ##### Harness identity diff --git a/packages/core/system-prompt/README.zh.md b/packages/core/system-prompt/README.zh.md index 0f9e7a2358..41729cdd1c 100644 --- a/packages/core/system-prompt/README.zh.md +++ b/packages/core/system-prompt/README.zh.md @@ -16,21 +16,21 @@ ### 公开 API -- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。 +- `ctx.systemPrompt.section(section: PromptSection): () => void`:贡献一个段。层由调用上下文的作用域决定:`agent.ctx` 只为该 agent 贡献,并在该处遮蔽同名全局段。一个 `complete: true` 段会在组装 waterfall 之后成为精确的完整提示词;有效 complete 段超过一个时,组装会被拒绝。同一层中的重复名称和非有限顺序会抛出。随调用 fiber 一并 dispose(资源释放)。 - `ctx.systemPrompt.tools(provider: (context: AssembleContext) => ToolProviderResult): () => void`:贡献工具 schema;每次组装时使用该次组装的上下文求值。`ToolProviderResult` = `{ schemas, knownNames? }`:`schemas` 是限制后的可见集合;`knownNames` 是限制前由 `toolOrder` 使用的全集。提供方不得返回名为 `TOOL_ORDER_REST` 的 schema。带作用域提供方只在其作用域的组装中查询。随调用 fiber 一并 dispose。 - `ctx.systemPrompt.variable(name: string, provider: (context) => string | undefined): () => void`:贡献提示词变量,在段文本中以 `{{name}}` 引用。带作用域变量会为该 agent 遮蔽同名全局变量。同层重复或无法引用的名称会抛出;`undefined` 表示「本次组装没有值」。随调用 fiber 一并 dispose。 -- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 waterfall 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,并返回其权威结果。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。当已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。 +- `ctx.systemPrompt.assemble(context?: AssembleContext): Promise`:为一个调用方组装提示词:将全局层与 `context.scope` 的层合并,并在变换 waterfall 前分离工具 schema。它经过按作用域筛选的 `system-prompt/assemble` waterfall,之后将一个有效的 complete 段恢复为唯一的提示词段落。可选的 `context.signal` 显式控制本次组装请求;提供方与监听器可以配合该信号,但不得将它保留给另一轮次。存在多个 complete 段、已配置的 `toolOrder` 指名提供方 `knownNames` 全集以外的工具,或提供方返回保留的其余项名称时,调用会被拒绝。 ### 实时事件 -`system-prompt/assemble` 是权威来源;替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发约定。 +`system-prompt/assemble` 对普通段落具有权威性;complete 段是在 waterfall 之后应用的最终提示词约束。替换条目的监听器必须保留任何活动 Code Mode 或结构化输出协议。筛选需要在呈现、查找与执行之间保持一致时,应使用 [`ToolRegistry.restrict()`](../tools/README.md)。注册表变更通知不经过筛选。[system-prompt.md](../../../docs/subsystems/system-prompt.md#cordis-surface) 的生成区块拥有签名与分发约定。 ### 关键类型 - `AssembleContext`:说明一次 `assemble()` 调用的用途。它可通过合并扩展;此处声明 `scope?: ScopeKey`(层选择器)与 `signal?: AbortSignal`(显式请求控制能力),而 `dsh-agent` 声明 `agent?: Agent`(类型化 DX 字段;绝不能在没有 `scope` 时设置,应使用 `assembleContextFor(agent, signal)`)。提供方必须容忍字段缺席,因为裸 `assemble()` 携带的是无作用域、无信号的空上下文。`signal` 是请求值,不是环境 Agent 执行 frame 的一部分。 -- `PromptSection`:`{ name, order, text }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。 +- `PromptSection`:`{ name, order, text, complete? }`。各段按 `order` 升序拼接。顺序区间:`-100` 是 harness 身份,`0` 是部署 persona,工具引导使用 `100–199`。协作式组装完成后,一个有效的 `complete` 段会抑制其他所有段落。 - `PromptAssembly`:`{ sections: AssembledSection[], tools: ToolSchema[], variables: Record }`。段文本到达时已解析,但尚未插值;`variables` 包含对上下文解析后的每个已注册变量。工具 schema 按设计属于组装结果:「模型获知自己能做什么」是一个连贯整体,尽管适配器把 schema 作为独立 wire 字段传输。 - `renderPrompt(assembly)`:插值每个段中的 `{{variable}}` 引用,删除空段,并用空行连接。严格规则:未知引用(使用 `Object.hasOwn` 查找,因此 `{{constructor}}` 等原型名称未知)、已注册但无值的引用、格式错误的完整 `{{…}}` 组,或一个起始 `{{` 没有打开完整组、但后面仍有 `}}`(`{{{model}}}`),都会抛出;明确失败胜过交付格式错误的提示词。孤立的 `{{` 如果后面任何位置都没有 `}}`,会按字面量通过;替换值绝不再次扫描。 @@ -41,7 +41,7 @@ - 段提供方:工具包拥有跨调用引导(`tool:bash`、`tool:read` 等);此插件拥有 `harness:identity` 与 `deployment:persona`。 - 变量提供方:agent loop(智能体循环)注册 `model` 与 `cwd`;任何插件都可以注册自己拥有的事实(未来的 `date`、git 状态等)。 - 工具 schema 提供方:`ToolRegistry` 自动将自身注册为工具提供方。 -- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果。 +- [`system-prompt/assemble` waterfall](#live-events):按调用方协作式修改或替换组装结果,之后再实施 complete 段约束。 设计原理:[提示词变量 Agent Note](../../../.agents/notes/implemented/architecture/2026-07-05-prompt-variables-and-tool-guidance-ownership.md)。 @@ -51,7 +51,7 @@ #### 模型看到的内容 -默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅为拥有完整兼容 persona 的部署省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。最终 `system-prompt/assemble` waterfall 结果是权威来源,因此专家监听器的变更决定交付的提示词与工具 schema。 +默认情况下,每次组装都从下方 harness 身份开始,然后在严格变量插值后追加已配置 persona 与有序插件段。`includeHarnessIdentity: false` 仅省略这个固定开场白。空段会消失;带作用域的段和变量可以为一个 agent 遮蔽全局项。`system-prompt/assemble` waterfall 决定交付的提示词与工具 schema,除非一个有效段声明自身为 complete;此时,该确切段落会成为完整的系统提示词,而 waterfall 得到的上下文、工具和变量保持不变。 ##### Harness 身份 diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index d601365df7..1f411b554f 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/core/system-prompt" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,18 +32,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/system-prompt/src/index.ts b/packages/core/system-prompt/src/index.ts index 23b5936e08..45a613cd8f 100644 --- a/packages/core/system-prompt/src/index.ts +++ b/packages/core/system-prompt/src/index.ts @@ -4,13 +4,13 @@ * @module @deepseek-ai/dsh-system-prompt */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { AnonymousEntries, NamedEntries, ScopedLayers, scopeTarget } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' import type { ContextSnapshotSection, ToolSchema } from '@deepseek-ai/dsh-llm' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { systemPrompt: SystemPrompt } @@ -21,7 +21,9 @@ declare module 'cordis' { * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners * receive only that scope's assemblies. The returned value is authoritative. * A supplied signal controls only this explicit assembly request and must not - * be retained to control later turns. + * be retained to control later turns. A registered complete section is + * restored after this waterfall, so listeners cannot add to or replace + * that scope's system prompt. * @param assembly - the mutable assembly built from registered providers. * @param context - the caller's per-assembly context. * @mode waterfall @@ -63,6 +65,13 @@ export interface PromptSection { * interpolated later, by {@link renderPrompt}. */ readonly text: string | ((context: AssembleContext) => string) + /** + * Treat this contribution as the complete system prompt. Assembly still + * runs the cooperative waterfall so tools, contexts, and variables can be + * resolved, then restores this exact section as the sole prompt section. + * More than one effective complete section makes assembly fail. + */ + readonly complete?: boolean } /** Dynamic model context materialized as a durable user-role snapshot. */ @@ -428,9 +437,11 @@ export class SystemPrompt extends Service { /** * Assemble global and scoped providers, detach tool parameters, apply * canonical ordering, then run the assembly waterfall. Scoped sections and - * variables shadow globals; the returned waterfall value is authoritative. + * variables shadow globals. The returned waterfall value is authoritative + * except that an effective complete section is restored afterwards as the + * sole prompt section. * @param context - the optional scope and plugin-defined assembly fields. - * @returns the authoritative post-waterfall assembly. + * @returns the post-waterfall assembly with any complete prompt enforced. */ // Keep configuration failures on the declared asynchronous error path. async assemble(context: AssembleContext = {}): Promise { @@ -467,13 +478,23 @@ export class SystemPrompt extends Service { collected.push(...schemas) for (const name of acceptedKnownNames) knownNames.add(name) } - const assembly: PromptAssembly = { - sections: [...sectionByName.values()] - .sort((a, b) => a.order - b.order) - .map(section => ({ + const sectionDefinitions = [...sectionByName.values()].sort((a, b) => a.order - b.order) + const completeSections = sectionDefinitions.filter(section => section.complete === true) + if (completeSections.length > 1) { + throw new Error(`multiple complete prompt sections are active: ${completeSections.map(section => JSON.stringify(section.name)).join(', ')}`) + } + let completeSection: AssembledSection | undefined + const sections = sectionDefinitions + .map((section) => { + const assembled = { name: section.name, text: typeof section.text === 'function' ? section.text(context) : section.text, - })), + } + if (section.complete === true) completeSection = { ...assembled } + return assembled + }) + const assembly: PromptAssembly = { + sections, contexts: [...contextByName.values()] .sort((a, b) => a.order - b.order) .map(entry => ({ @@ -483,10 +504,12 @@ export class SystemPrompt extends Service { tools: orderTools(collected, this.toolOrder, knownNames), variables, } - return this.ctx.waterfall( + const transformed = await this.ctx.waterfall( scopeTarget(this, scope), 'system-prompt/assemble', assembly, context, () => Promise.resolve(assembly), ) + if (completeSection === undefined) return transformed + return { ...transformed, sections: [completeSection] } } } diff --git a/packages/core/system-prompt/src/invariant.ts b/packages/core/system-prompt/src/invariant.ts index 04dc65e7ad..bbe587516f 100644 --- a/packages/core/system-prompt/src/invariant.ts +++ b/packages/core/system-prompt/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned prompt-assembly invariants. @module @deepseek-ai/dsh-system-prompt/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { PromptAssembly } from './index.ts' diff --git a/packages/core/system-prompt/tests/invariant.spec.ts b/packages/core/system-prompt/tests/invariant.spec.ts index ace65d2bc9..74c3648494 100644 --- a/packages/core/system-prompt/tests/invariant.spec.ts +++ b/packages/core/system-prompt/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { PromptAssembly } from '@deepseek-ai/dsh-system-prompt' import * as SystemPromptInvariant from '@deepseek-ai/dsh-system-prompt/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/core/system-prompt/tests/scoped.spec.ts b/packages/core/system-prompt/tests/scoped.spec.ts index 704a3e769c..1498966cc6 100644 --- a/packages/core/system-prompt/tests/scoped.spec.ts +++ b/packages/core/system-prompt/tests/scoped.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createScope, scopeOf } from '@deepseek-ai/dsh-scope' import type { Scope, ScopeKey } from '@deepseek-ai/dsh-scope' import SystemPrompt, { TOOL_ORDER_REST, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/system-prompt/tests/system-prompt.spec.ts b/packages/core/system-prompt/tests/system-prompt.spec.ts index 834c17c341..8ab6c37ea7 100644 --- a/packages/core/system-prompt/tests/system-prompt.spec.ts +++ b/packages/core/system-prompt/tests/system-prompt.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { AssembleContext, PromptAssembly, renderContextSnapshot, renderPrompt } from '@deepseek-ai/dsh-system-prompt' /** @@ -264,6 +264,34 @@ describe('SystemPrompt', () => { expect(assembly.sections).toHaveLength(0) }) + it('restores one complete section after the assembly waterfall', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'complete', order: 10, text: 'Exact prompt.', complete: true }) + ctx.systemPrompt.section({ name: 'extra', order: 20, text: 'extra' }) + ctx.on('system-prompt/assemble', async (assembly, _context, next) => { + const complete = assembly.sections.find(section => section.name === 'complete') + if (complete === undefined) throw new Error('complete section missing before waterfall') + complete.text = 'mutated' + assembly.sections.push({ name: 'late', text: 'late' }) + return next() + }, { prepend: true }) + + expect((await ctx.systemPrompt.assemble()).sections).toEqual([ + { name: 'complete', text: 'Exact prompt.' }, + ]) + }) + + it('rejects multiple effective complete sections', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + ctx.systemPrompt.section({ name: 'first', order: 10, text: 'first', complete: true }) + ctx.systemPrompt.section({ name: 'second', order: 20, text: 'second', complete: true }) + + await expect(ctx.systemPrompt.assemble()) + .rejects.toThrow('multiple complete prompt sections are active: "first", "second"') + }) + it('assembles snapshots so one-step mutations do not leak into future assemblies', async () => { const ctx = new Context() await ctx.plugin(SystemPrompt) diff --git a/packages/core/system-prompt/tests/tool-order.spec.ts b/packages/core/system-prompt/tests/tool-order.spec.ts index 16eff6e354..6085276900 100644 --- a/packages/core/system-prompt/tests/tool-order.spec.ts +++ b/packages/core/system-prompt/tests/tool-order.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { PromptAssembly, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { ToolSchema } from '@deepseek-ai/dsh-llm' diff --git a/packages/core/tools/README.i18n.yaml b/packages/core/tools/README.i18n.yaml index e3a9b36a95..317f583994 100644 --- a/packages/core/tools/README.i18n.yaml +++ b/packages/core/tools/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/core/tools/README.md -README.md: 21851ca887147364c76612bae2e6a00ebdccec39 -README.zh.md: aec3b434e52f473001505bbea5212d5e247eb46f +README.md: cc7b323ae1de917e93e243e97bd5cf5937ecdca4 +README.zh.md: 8d4ae42596483f77aa82b23a0b41168465e2b165 diff --git a/packages/core/tools/README.md b/packages/core/tools/README.md index 21851ca887..cc7b323ae1 100644 --- a/packages/core/tools/README.md +++ b/packages/core/tools/README.md @@ -66,7 +66,7 @@ First-party plugin authors can use the `defineTool()` helper (exported from this ```ts import { readFile } from 'node:fs/promises' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' declare const ctx: Context diff --git a/packages/core/tools/README.zh.md b/packages/core/tools/README.zh.md index aec3b434e5..8d4ae42596 100644 --- a/packages/core/tools/README.zh.md +++ b/packages/core/tools/README.zh.md @@ -66,7 +66,7 @@ tools: ```ts import { readFile } from 'node:fs/promises' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' declare const ctx: Context diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 0fcc66b497..6634098f99 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/core/tools" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -34,18 +41,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-code-runtime": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-code-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -56,6 +63,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/tools/src/index.ts b/packages/core/tools/src/index.ts index 86c69d9307..e8f1033ab3 100644 --- a/packages/core/tools/src/index.ts +++ b/packages/core/tools/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-tools */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { AnonymousEntries, NamedEntries, ScopedLayers, scopeOf, scopeTarget } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer, Scoped } from '@deepseek-ai/dsh-scope' import type { CallId, ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' @@ -120,7 +120,7 @@ export type { WebSource, } from './presentation.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { tools: ToolRegistry } @@ -786,7 +786,7 @@ export class ToolRegistry extends Service { scope => new ToolLayer(scope), () => { this.ctx.emit('tools/change') }, ) - /** Presentation for agents that declare none; {@link presentAs} shadows it per agent. */ + /** Presentation for scopes that declare none; {@link presentAs} shadows it per scope. */ private readonly defaultMode: ToolPresentationMode private readonly maxParallelSubCalls: number /** @@ -811,7 +811,7 @@ export class ToolRegistry extends Service { /** * The generated-SDK prompt section, registered globally by a code-mode - * deployment and per agent by {@link presentAs}. + * deployment and per scope by {@link presentAs}. * * The body regenerates from the CALLING scope, and renders empty for an * agent presenting natively — an agent that opted out under a code-mode @@ -880,12 +880,14 @@ export class ToolRegistry extends Service { } /** - * Present this agent's tools in `mode` instead of the deployment default. + * Present the calling scope's tools in `mode` instead of the deployment + * default. Nearest scope on the chain wins, so a preset's standing + * declaration covers every agent joined under it. * - * Scoped only, and one declaration per agent: this is how an agent preset - * composes a Code Mode agent beside native ones in the same process, and a + * Scoped only, and one declaration per scope: this is how an agent preset + * composes Code Mode agents beside native ones in the same process, and a * process-global override would be the `mode` config field instead. - * @param mode - the presentation this agent's model sees. + * @param mode - the presentation the covered agents' models see. * @returns the exact disposer that restores the deployment default. */ presentAs(mode: ToolPresentationMode): () => void { @@ -898,14 +900,14 @@ export class ToolRegistry extends Service { ctx, (layer) => { if (layer.mode !== undefined) { - throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this agent; one composition selects one presentation`) + throw new Error(`tools.presentAs("${mode}") conflicts with "${layer.mode}" already declared for this scope; one composition selects one presentation`) } layer.mode = mode return () => { layer.mode = undefined } }, { label: 'tools.presentAs()' }, ) - // The SDK section is per agent for the same reason the mode is. Under a + // The SDK section is per scope for the same reason the mode is. Under a // deployment that already defaults to a code mode this shadows the // global registration with an identical body, which costs nothing and // keeps one rule instead of a case analysis. diff --git a/packages/core/tools/src/invariant.ts b/packages/core/tools/src/invariant.ts index a0d9487857..5489f61d80 100644 --- a/packages/core/tools/src/invariant.ts +++ b/packages/core/tools/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned tool-pipeline invariants. @module @deepseek-ai/dsh-tools/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ToolExecution, ToolExecutionResult } from './index.ts' diff --git a/packages/core/tools/tests/code-mode.spec.ts b/packages/core/tools/tests/code-mode.spec.ts index 1794e7e36e..c1d49808d8 100644 --- a/packages/core/tools/tests/code-mode.spec.ts +++ b/packages/core/tools/tests/code-mode.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' diff --git a/packages/core/tools/tests/execution-mode.spec.ts b/packages/core/tools/tests/execution-mode.spec.ts index 54c4687c60..cbac6fcecc 100644 --- a/packages/core/tools/tests/execution-mode.spec.ts +++ b/packages/core/tools/tests/execution-mode.spec.ts @@ -1,7 +1,7 @@ /** Covers fail-closed per-call classification and model-schema isolation. */ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { diff --git a/packages/core/tools/tests/execution-signal-types.spec.ts b/packages/core/tools/tests/execution-signal-types.spec.ts index dd878d648e..0ac4ac85c0 100644 --- a/packages/core/tools/tests/execution-signal-types.spec.ts +++ b/packages/core/tools/tests/execution-signal-types.spec.ts @@ -1,5 +1,5 @@ import { describe, expectTypeOf, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import type { diff --git a/packages/core/tools/tests/gen-tool-catalog.spec.ts b/packages/core/tools/tests/gen-tool-catalog.spec.ts index 2e59ffe32e..dcd9ca01c6 100644 --- a/packages/core/tools/tests/gen-tool-catalog.spec.ts +++ b/packages/core/tools/tests/gen-tool-catalog.spec.ts @@ -23,7 +23,7 @@ describe('gen-tool-catalog collectToolCatalog', () => { it('boots every shipped tool package and harvests its model-facing schemas', async () => { const catalog = await collectToolCatalog() const names = catalog.flatMap(entry => entry.schemas.map(s => s.name)).sort() - expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) + expect(names).toEqual(['ask_user_question', 'bash', 'bash', 'cordis_inspect', 'cordis_mount', 'cordis_unmount', 'create_goal', 'edit', 'exit_plan_mode', 'get_goal', 'glob', 'grep', 'interrupt_agent', 'list_agents', 'lsp', 'pwsh', 'ralph', 'read', 'read_image', 'report', 'run_code', 'send_message', 'session_event_read', 'session_event_search', 'session_event_trace', 'session_search', 'session_trace', 'skill', 'str_replace_editor', 'subagent', 'task_kill', 'task_list', 'task_output', 'terminal_close', 'terminal_list', 'terminal_open', 'terminal_read', 'terminal_send', 'terminal_signal', 'todo_write', 'update_goal', 'web_fetch', 'web_search', 'workflow', 'write']) // Every tool carries a JSON-Schema `parameters` object (what the model sees). for (const entry of catalog) { for (const schema of entry.schemas) { diff --git a/packages/core/tools/tests/invariant.spec.ts b/packages/core/tools/tests/invariant.spec.ts index 8a60c41331..28badfbc58 100644 --- a/packages/core/tools/tests/invariant.spec.ts +++ b/packages/core/tools/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { CallId } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/core/tools/tests/scoped.spec.ts b/packages/core/tools/tests/scoped.spec.ts index 8f7be45b19..6f03e0aef4 100644 --- a/packages/core/tools/tests/scoped.spec.ts +++ b/packages/core/tools/tests/scoped.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from 'cordis' -import type { Events } from 'cordis' +import { Context } from '@deepseek-ai/cordis' +import type { Events } from '@deepseek-ai/cordis' import { bindScopeParent, createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/core/tools/tests/tools.spec.ts b/packages/core/tools/tests/tools.spec.ts index cfb1f6db05..217875898d 100644 --- a/packages/core/tools/tests/tools.spec.ts +++ b/packages/core/tools/tests/tools.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, HarnessError, type ContentBlock } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index f4fbb3a9fe..977c51013c 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-credentials-local", "description": "File-backed credentials provider ($DSH_HOME/.env under the live process environment) for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/credentials/credentials-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-atomic-write": "^0.0.1", - "@deepseek-ai/dsh-credentials": "^0.0.1", - "@deepseek-ai/dsh-environment": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "chokidar": "^4.0.3", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "yaml": "^2.9.0" }, "devDependencies": { @@ -43,6 +50,6 @@ "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/credentials/credentials-local/src/index.ts b/packages/credentials/credentials-local/src/index.ts index e7800253f3..c61a7521e9 100644 --- a/packages/credentials/credentials-local/src/index.ts +++ b/packages/credentials/credentials-local/src/index.ts @@ -35,8 +35,8 @@ * @module @deepseek-ai/dsh-credentials-local */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { watch as chokidarWatch } from 'chokidar' import { mkdir, readFile, stat } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' diff --git a/packages/credentials/credentials-local/src/invariant.ts b/packages/credentials/credentials-local/src/invariant.ts index 454f2b808e..0ea27d0071 100644 --- a/packages/credentials/credentials-local/src/invariant.ts +++ b/packages/credentials/credentials-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-credentials-local' diff --git a/packages/credentials/credentials-local/tests/drain.spec.ts b/packages/credentials/credentials-local/tests/drain.spec.ts index 9cf4e600fb..bbd2feec26 100644 --- a/packages/credentials/credentials-local/tests/drain.spec.ts +++ b/packages/credentials/credentials-local/tests/drain.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/credentials/credentials-local/tests/local.spec.ts b/packages/credentials/credentials-local/tests/local.spec.ts index e9f65356bd..d66aa83047 100644 --- a/packages/credentials/credentials-local/tests/local.spec.ts +++ b/packages/credentials/credentials-local/tests/local.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' diff --git a/packages/credentials/credentials-local/tests/review-fixes.spec.ts b/packages/credentials/credentials-local/tests/review-fixes.spec.ts index b7839cf538..6a9aad7ea4 100644 --- a/packages/credentials/credentials-local/tests/review-fixes.spec.ts +++ b/packages/credentials/credentials-local/tests/review-fixes.spec.ts @@ -3,7 +3,7 @@ // broken observer never fails a committed write), and the YAML document // editor's isolation between entries. import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/credentials/credentials-local/tests/watcher.spec.ts b/packages/credentials/credentials-local/tests/watcher.spec.ts index e49f1421b4..11307cde0d 100644 --- a/packages/credentials/credentials-local/tests/watcher.spec.ts +++ b/packages/credentials/credentials-local/tests/watcher.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/credentials/credentials/README.i18n.yaml b/packages/credentials/credentials/README.i18n.yaml index 053af47617..756ba1ba04 100644 --- a/packages/credentials/credentials/README.i18n.yaml +++ b/packages/credentials/credentials/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/credentials/credentials/README.md -README.md: fc4fb16991a3396f106bed65b468d0ae538bbab8 -README.zh.md: 6007618e5dc917ebc38fd322b34427cb5281e70c +README.md: 5cfeb8e6656fabd638ea2126e56bc66391f0ca01 +README.zh.md: 28a029f0e4909fb14a1208458dcd1d22afe4abb9 diff --git a/packages/credentials/credentials/README.md b/packages/credentials/credentials/README.md index fc4fb16991..5cfeb8e665 100644 --- a/packages/credentials/credentials/README.md +++ b/packages/credentials/credentials/README.md @@ -13,7 +13,7 @@ Credential Service Definition (`ctx.credentials`). One doctrine, three consequen ## Surface ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' declare const ctx: Context diff --git a/packages/credentials/credentials/README.zh.md b/packages/credentials/credentials/README.zh.md index 6007618e5d..28a029f0e4 100644 --- a/packages/credentials/credentials/README.zh.md +++ b/packages/credentials/credentials/README.zh.md @@ -13,7 +13,7 @@ ## 接口 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { credentialRef } from '@deepseek-ai/dsh-credentials' declare const ctx: Context diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 42dd183fc4..68c6be2f1b 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/credentials/credentials" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,13 +32,13 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/credentials/credentials/src/index.ts b/packages/credentials/credentials/src/index.ts index 5f1efb010c..b4fb1569f1 100644 --- a/packages/credentials/credentials/src/index.ts +++ b/packages/credentials/credentials/src/index.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-credentials */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { Branded } from '@deepseek-ai/dsh-brand' /** Nominal reference to one credential: a POSIX-style environment-variable name. */ @@ -46,7 +46,7 @@ export interface CredentialInfo { writable: boolean } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { credentials: Credentials } diff --git a/packages/credentials/credentials/src/invariant.ts b/packages/credentials/credentials/src/invariant.ts index 23c2dda45b..790388ffae 100644 --- a/packages/credentials/credentials/src/invariant.ts +++ b/packages/credentials/credentials/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-credentials/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-credentials' diff --git a/packages/credentials/credentials/tests/credentials.spec.ts b/packages/credentials/credentials/tests/credentials.spec.ts index 9b4cf7b1e8..a4676a2ea8 100644 --- a/packages/credentials/credentials/tests/credentials.spec.ts +++ b/packages/credentials/credentials/tests/credentials.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { credentialRef } from '../src/index.ts' import type { CredentialRef } from '../src/index.ts' import { MemoryCredentials } from './memory.ts' diff --git a/packages/credentials/credentials/tests/invariant.spec.ts b/packages/credentials/credentials/tests/invariant.spec.ts index dccde4843f..f1af9d28b8 100644 --- a/packages/credentials/credentials/tests/invariant.spec.ts +++ b/packages/credentials/credentials/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import { credentialRef } from '../src/index.ts' import * as CredentialsInvariant from '../src/invariant.ts' diff --git a/packages/credentials/credentials/tests/memory.ts b/packages/credentials/credentials/tests/memory.ts index dc1ed77a06..5b8ab32262 100644 --- a/packages/credentials/credentials/tests/memory.ts +++ b/packages/credentials/credentials/tests/memory.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { Credentials } from '../src/index.ts' import type { CredentialInfo, CredentialRef, ResolvedCredential } from '../src/index.ts' diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index 5abacb278a..dafbb57b00 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/e2b/e2b" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "e2b": "2.29.1", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-loader-smoke": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/e2b/e2b/src/index.ts b/packages/e2b/e2b/src/index.ts index 906941d421..b428f45cf3 100644 --- a/packages/e2b/e2b/src/index.ts +++ b/packages/e2b/e2b/src/index.ts @@ -6,8 +6,8 @@ import { randomUUID } from 'node:crypto' import { posix } from 'node:path' -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { FileType, Sandbox, SandboxNotFoundError } from 'e2b' export { @@ -60,7 +60,7 @@ interface SchemaResolvedConfig extends Config { timeoutMs: number } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { e2b: E2BSandboxService } diff --git a/packages/e2b/e2b/src/invariant.ts b/packages/e2b/e2b/src/invariant.ts index 891cabb2db..63bf988e50 100644 --- a/packages/e2b/e2b/src/invariant.ts +++ b/packages/e2b/e2b/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-e2b' diff --git a/packages/e2b/e2b/tests/composition.e2e.ts b/packages/e2b/e2b/tests/composition.e2e.ts index 6da102a827..76f54ba7fd 100644 --- a/packages/e2b/e2b/tests/composition.e2e.ts +++ b/packages/e2b/e2b/tests/composition.e2e.ts @@ -1,7 +1,7 @@ import { access } from 'node:fs/promises' import { join, posix } from 'node:path' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/e2b/e2b/tests/e2b.spec.ts b/packages/e2b/e2b/tests/e2b.spec.ts index b108bc68b0..9b9e4b0b9d 100644 --- a/packages/e2b/e2b/tests/e2b.spec.ts +++ b/packages/e2b/e2b/tests/e2b.spec.ts @@ -1,6 +1,6 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' import type { Mock } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Sandbox as SandboxType } from 'e2b' import E2BSandboxService, { e2bControlEnvs, diff --git a/packages/e2b/fs-e2b/README.i18n.yaml b/packages/e2b/fs-e2b/README.i18n.yaml index 64460f7e87..feb950b196 100644 --- a/packages/e2b/fs-e2b/README.i18n.yaml +++ b/packages/e2b/fs-e2b/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/e2b/fs-e2b/README.md -README.md: 1b66e84defb56cbfaa4a91d6ba6b48377fb52ca9 -README.zh.md: d9cd3ce1e109bf6b0b7fae02157d1ec6be51e575 +README.md: 9171989f968f144593107eb918fe75cd12de7768 +README.zh.md: 9f50bbe4c37bbbfb641690a690be45dbb5158258 diff --git a/packages/e2b/fs-e2b/README.md b/packages/e2b/fs-e2b/README.md index 1b66e84def..9171989f96 100644 --- a/packages/e2b/fs-e2b/README.md +++ b/packages/e2b/fs-e2b/README.md @@ -9,6 +9,7 @@ E2B implementation of the [`@deepseek-ai/dsh-fs`](../../fs/fs/README.md) provide - **Remote identity and metadata** — relative paths resolve as POSIX paths against the caller cwd or `ctx.e2b.cwd`; GNU `realpath -mz` supplies canonical target identity without requiring the final file to exist, and ASCII/base64 plus strict NUL framing preserves newline and multibyte paths across the decoded SDK transport. `stat`, no-follow `lstat`, and stable one-level directory listings project E2B metadata into the filesystem seam; listings reuse returned metadata and resolve symbolic-link entries sequentially. Versions are opaque hashes of E2B metadata plus a per-write extended attribute. - **Execution-world paths** — canonical targets expose absolute POSIX process paths, percent-encoded `file:` URIs, and provider-owned containment checks, so generic subprocess consumers never parse E2B target ids or apply host path rules. - **UTF-8 reads** — whole reads and streamed reads preserve cross-chunk decoding, reject invalid UTF-8, and use the seam's 8192-byte NUL sample for binary detection. The model-facing tool still owns size selection and line windowing. +- **Bounded raw-byte reads** — `readBytes` short-circuits on the stat size before any content transfer, then streams the remote object and cancels the stream at the first chunk past `maxBytes` (`FS_TOO_LARGE`), so neither an at-rest oversized file nor a post-stat grower is buffered whole in host memory. The empty-file quirk of the pinned SDK (content-length 0 returns `''` in stream format) yields an empty result. - **Atomic mutations** — writes create a random sibling staging directory, change it to mode `0700` before uploading content, and preserve an existing file's POSIX mode. Replacements publish through E2B's same-filesystem atomic rename. A guarded `createIfAbsent` publishes with remote `ln -T` instead, making the commit atomically no-replace even when a directory appears at the destination; metadata read from the staged file before that commit is projected to the target path for the returned version, so no fallible metadata request follows either commit point. E2B creates missing parent directories. Literal edits LF-normalize for matching, restore dominant CRLF storage, and serialize mutations per canonical target within the host process. - **Failures and cancellation** — E2B not-found, permission, abort, and other controller failures map to the existing `FsError` vocabulary. Cancellation is best-effort at earlier SDK request boundaries and checked immediately before publication. The signal is not forwarded into the rename or guarded-link commit, so cancellation cannot interrupt atomic publication or turn a committed write into a reported failure. diff --git a/packages/e2b/fs-e2b/README.zh.md b/packages/e2b/fs-e2b/README.zh.md index d9cd3ce1e1..9f50bbe4c3 100644 --- a/packages/e2b/fs-e2b/README.zh.md +++ b/packages/e2b/fs-e2b/README.zh.md @@ -9,6 +9,7 @@ - **远程身份与元数据**:相对路径以调用方 cwd 或 `ctx.e2b.cwd` 为基准,按照 POSIX 路径解析;GNU `realpath -mz` 提供规范化目标身份,且不要求最终文件存在;ASCII/base64 加严格 NUL 分帧会在已解码的 SDK 传输中保留含换行符和多字节字符的路径。`stat`、不跟随链接的 `lstat` 和稳定的单层目录列表会把 E2B 元数据投影到文件系统 seam;目录列表会复用已返回的元数据,并依次解析符号链接条目。版本是 E2B 元数据与每次写入设置的扩展属性所组成的不透明哈希。 - **执行世界路径**:规范化目标公开绝对 POSIX 进程路径、百分号编码的 `file:` URI,以及由提供方负责的包含关系检查,因此通用进程管理消费方无需解析 E2B 目标 ID,也不会套用宿主路径规则。 - **UTF-8 读取**:完整读取和流式读取会保留跨分片解码、拒绝无效 UTF-8,并使用 seam 的 8192 字节 NUL 样本检测二进制内容。面向模型的工具仍负责选择大小和行窗口。 +- **有界原始字节读取**:`readBytes` 在任何内容传输之前先按 stat 大小短路,然后流式读取远程对象,并在第一个超过 `maxBytes` 的分片处取消流(`FS_TOO_LARGE`),因此静态超限文件和 stat 后增长的文件都不会被完整缓冲进宿主内存。所钉版本 SDK 的空文件怪癖(content-length 为 0 时 stream 格式返回 `''`)产生空结果。 - **原子变更**:写入会创建随机的同级暂存目录,在上传内容前将其 mode 改为 `0700`,并保留现有文件的 POSIX mode。替换操作通过 E2B 的同一文件系统原子重命名发布。带防护的 `createIfAbsent` 改用远程 `ln -T` 发布,即使目标位置出现目录,也能使提交具备原子且不替换的语义;系统会把提交前从暂存文件读取的元数据投影到目标路径,以生成返回的版本,因此任何一类提交点之后都不会再进行可能失败的元数据请求。E2B 会创建缺失的父目录。字面量编辑匹配时会规范化为 LF,存储时恢复占主导的 CRLF,并在宿主进程内按规范化目标串行执行变更。 - **失败与取消**:E2B 的未找到、权限、中止及其他控制器故障会映射到现有 `FsError` 词汇。取消在更早的 SDK 请求边界上采用尽力而为语义,并在发布前立即检查。信号不会传入 rename 或防护链接提交,因此取消无法中断原子发布,也不会把已提交的写入报告为失败。 diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index 02925ee7c4..1f6e085e5f 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/e2b/fs-e2b" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-e2b": "^0.0.1", - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-e2b": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-e2b": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index b26338ffa4..f78a382495 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -90,6 +90,24 @@ function commandOpts(signal: AbortSignal | undefined): { envs: Record> { + try { + // The pinned SDK's stream overload lies for empty files: content-length 0 + // returns '' instead of a ReadableStream. + const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as + ReadableStream | string + return typeof read === 'string' + ? new ReadableStream({ start(controller) { controller.close() } }) + : read + } catch (error: unknown) { + throw mapError(error, 'read', target.displayPath, signal) + } +} + function entryType(entry: EntryInfo): FsInfo['type'] { switch (entry.type) { case FileType.FILE: @@ -227,21 +245,57 @@ export class E2BFileSystem extends FileSystem { } } + override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise { + const sandbox = await this.ctx.e2b.getSandbox() + const info = await this.requireRegular(target, signal) + if (info.size !== undefined && info.size > maxBytes) { + throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') + } + const stream = await openReadStream(sandbox, target, signal) + const reader = stream.getReader() + const chunks: Uint8Array[] = [] + let bytes = 0 + let completed = false + try { + while (true) { + assertNotAborted(signal, 'read') + const next = await reader.read() + if (next.done) break + // The stat preflight covers the at-rest case; this streamed bound stops + // a post-stat grower without transferring past the first overflowing chunk. + bytes += next.value.byteLength + if (bytes > maxBytes) { + throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') + } + chunks.push(next.value) + } + completed = true + } catch (error: unknown) { + throw mapError(error, 'read', target.displayPath, signal) + } finally { + if (!completed) { + try { + await reader.cancel() + } catch (_streamCancellationFailure) { + // The read already failed; a cancellation failure on the abandoned + // remote stream adds nothing actionable for the caller. + } + } + reader.releaseLock() + } + const whole = new Uint8Array(bytes) + let offset = 0 + for (const chunk of chunks) { + whole.set(chunk, offset) + offset += chunk.byteLength + } + return whole + } + override async streamText(target: FsTarget, signal?: AbortSignal): Promise> { const sandbox = await this.ctx.e2b.getSandbox() await this.requireRegular(target, signal) - let stream: ReadableStream - try { - // The pinned SDK's stream overload lies for empty files: content-length 0 - // returns '' instead of a ReadableStream. - const read = await sandbox.files.read(String(target.targetKey), { format: 'stream', ...signalOpts(signal) }) as - ReadableStream | string - stream = typeof read === 'string' - ? new ReadableStream({ start(controller) { controller.close() } }) - : read - } catch (error: unknown) { - throw mapError(error, 'read', target.displayPath, signal) - } + const stream = await openReadStream(sandbox, target, signal) const displayPath = target.displayPath return { async *[Symbol.asyncIterator](): AsyncGenerator { @@ -412,10 +466,11 @@ export class E2BFileSystem extends FileSystem { } } - private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise { + private async requireRegular(target: FsTarget, signal?: AbortSignal): Promise { const info = await this.stat(target, signal) if (info === undefined) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + return info } private checkWriteIntent(existing: EntryInfo | undefined, expected: FsWriteIntent | undefined, target: FsTarget): void { diff --git a/packages/e2b/fs-e2b/src/invariant.ts b/packages/e2b/fs-e2b/src/invariant.ts index 9f14bb37a6..891b157aec 100644 --- a/packages/e2b/fs-e2b/src/invariant.ts +++ b/packages/e2b/fs-e2b/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-e2b' diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 6ee3df6543..7ac3d4a764 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer' import { dirname, posix } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CommandExitError, FileNotFoundError, @@ -40,6 +40,7 @@ class FakeRemote { readonly links: Array<{ from: string; to: string }> = [] readonly removals: string[] = [] readonly commands: string[] = [] + readonly reads: Array<{ path: string; format: 'bytes' | 'stream' }> = [] streamChunks: Uint8Array[] | undefined streamKeepOpen = false readonly streamCancel = vi.fn() @@ -157,6 +158,7 @@ class FakeRemote { }, read: async (path: string, options: { format: 'bytes' | 'stream'; signal?: AbortSignal }): Promise | string> => { this.checkAbort(options) + this.reads.push({ path, format: options.format }) if (this.nextReadError !== undefined) { const error = this.nextReadError this.nextReadError = undefined @@ -471,6 +473,42 @@ describe('E2BFileSystem identity, metadata, and reads', () => { await expectCode(fs.streamText(raced), 'FS_NOT_FOUND') }) + it('readBytes returns raw content, enforces the byte cap, and maps failures', async () => { + const remote = new FakeRemote() + remote.file('/workspace/img.bin', [0x89, 0, 0xff, 0x47]) + remote.dir('/workspace/directory') + const { fs } = await setup(remote) + const target = await fs.resolve('img.bin') + expect(Array.from(await fs.readBytes(target, undefined, 4))).toEqual([0x89, 0, 0xff, 0x47]) + expect(remote.reads).toEqual([{ path: '/workspace/img.bin', format: 'stream' }]) + remote.reads.length = 0 + await expectCode(fs.readBytes(target, undefined, 3), 'FS_TOO_LARGE') + expect(remote.reads).toEqual([]) + await expectCode(fs.readBytes(await fs.resolve('missing'), undefined, 4), 'FS_NOT_FOUND') + await expectCode(fs.readBytes(await fs.resolve('directory'), undefined, 4), 'FS_NOT_REGULAR_FILE') + + const live = new AbortController() + expect((await fs.readBytes(target, live.signal, 4)).byteLength).toBe(4) + remote.nextReadError = new DOMException('aborted', 'AbortError') + await expectCode(fs.readBytes(target, undefined, 4), 'FS_ABORTED') + }) + + it('readBytes bounds a post-stat grower mid-stream and reads an empty file through the SDK quirk', async () => { + const remote = new FakeRemote() + remote.file('/workspace/grow.bin', [1, 1, 1, 1]) + remote.file('/workspace/empty.bin', '') + const { fs } = await setup(remote) + + remote.streamChunks = [bytes([1, 1, 1]), bytes([1, 2, 2])] + remote.streamKeepOpen = true + await expectCode(fs.readBytes(await fs.resolve('grow.bin'), undefined, 4), 'FS_TOO_LARGE') + expect(remote.streamCancel).toHaveBeenCalledOnce() + + remote.streamChunks = undefined + remote.streamKeepOpen = false + expect((await fs.readBytes(await fs.resolve('empty.bin'), undefined, 4)).byteLength).toBe(0) + }) + it('honors aborts before and during remote reads', async () => { const remote = new FakeRemote() remote.file('/workspace/a', 'a') diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index 007b88f1c9..dfbdf73da5 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/e2b/subprocess-e2b" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,20 +32,20 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-e2b": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-e2b": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-e2b": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/e2b/subprocess-e2b/src/index.ts b/packages/e2b/subprocess-e2b/src/index.ts index 3c0063c457..bd3978f818 100644 --- a/packages/e2b/subprocess-e2b/src/index.ts +++ b/packages/e2b/subprocess-e2b/src/index.ts @@ -6,8 +6,8 @@ import { randomUUID } from 'node:crypto' import { posix } from 'node:path' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { SubprocessService } from '@deepseek-ai/dsh-subprocess' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import type { diff --git a/packages/e2b/subprocess-e2b/src/invariant.ts b/packages/e2b/subprocess-e2b/src/invariant.ts index 9f8b8fb739..733310245a 100644 --- a/packages/e2b/subprocess-e2b/src/invariant.ts +++ b/packages/e2b/subprocess-e2b/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess-e2b' diff --git a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts index 153b144ccd..6272320e0c 100644 --- a/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/subprocess.spec.ts @@ -1,5 +1,5 @@ import { once } from 'node:events' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CommandExitError, FileNotFoundError, diff --git a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts index a142817870..1e3e296a7f 100644 --- a/packages/e2b/subprocess-e2b/tests/terminal.spec.ts +++ b/packages/e2b/subprocess-e2b/tests/terminal.spec.ts @@ -1,6 +1,6 @@ import { Buffer } from 'node:buffer' import { once } from 'node:events' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { CommandExitError, diff --git a/packages/examples/README.i18n.yaml b/packages/examples/README.i18n.yaml index 2270947eea..7cd26439c2 100644 --- a/packages/examples/README.i18n.yaml +++ b/packages/examples/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/examples/README.md -README.md: d8369b1e263e72c7b0ac1687c3b14a5d723ab944 -README.zh.md: acb402e925f692beaacbe0ab4e029691d664dbe8 +README.md: 0048d14ec49776f036d841bbc0579a6867e513bb +README.zh.md: 1b7acc5646071f4fc21e9238e4e440c192a1e82a diff --git a/packages/examples/README.md b/packages/examples/README.md index d8369b1e26..0048d14ec4 100644 --- a/packages/examples/README.md +++ b/packages/examples/README.md @@ -10,7 +10,7 @@ Pre-composed plugin bundles a thin leaf `cordis.yml` loads instead of assembling | [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP automation application bundle | | [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | External-config JSON-RPC runtime | -`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh run`; no package in this directory provides it. +`agent-spine-demo` is the shared bundle; `acp-demo` adds its automation entry point, while `jsonrpc-demo` boots a deployment-owned plugin tree. Product one-shot execution belongs to `dsh --profile headless`; no package in this directory provides it. These packages are not product API. Product seams and entry points remain in their owning groups; demo bundles select concrete compositions. diff --git a/packages/examples/README.zh.md b/packages/examples/README.zh.md index acb402e925..1b7acc5646 100644 --- a/packages/examples/README.zh.md +++ b/packages/examples/README.zh.md @@ -10,7 +10,7 @@ | [`acp-demo/`](acp-demo/README.md) | `@deepseek-ai/dsh-acp-demo` | ACP(Agent Client Protocol)自动化应用组合包 | | [`jsonrpc-demo/`](jsonrpc-demo/README.md) | `@deepseek-ai/dsh-jsonrpc-demo` | 外部配置 JSON-RPC 运行时 | -`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh run` 提供;本目录没有任何包提供该功能。 +`agent-spine-demo` 是共享组合包;`acp-demo` 添加自动化入口,`jsonrpc-demo` 则启动由部署方拥有的插件树。产品单次执行由 `dsh --profile headless` 提供;本目录没有任何包提供该功能。 这些包不是产品 API。产品 seam 与产品入口仍位于各自的归属组;演示组合包选择具体组合。 diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index ce4b65466a..1d35f2b23f 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-acp-demo", "description": "ACP automation server app: agent spine + JSONL persistence + ACP transport, with a JSON-RPC stdio bin", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/examples/acp-demo" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -33,24 +40,24 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-acp": "^0.0.1", - "@deepseek-ai/dsh-agent-spine-demo": "^0.0.1", - "@deepseek-ai/dsh-app-boot": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session-checkpoint-policy": "^0.0.1", - "@deepseek-ai/dsh-session-persistence-jsonl": "^0.0.1", - "@deepseek-ai/dsh-session-query": "^0.0.1", - "@deepseek-ai/dsh-session-query-sqlite": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-workspace-context": "^0.0.1", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.17.0" + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-acp": "workspace:^", + "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", + "@deepseek-ai/dsh-app-boot": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session-checkpoint-policy": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-session-query-sqlite": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", @@ -63,7 +70,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.17.0" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/examples/acp-demo/src/index.ts b/packages/examples/acp-demo/src/index.ts index 8a900dd3cd..1bd0c44459 100644 --- a/packages/examples/acp-demo/src/index.ts +++ b/packages/examples/acp-demo/src/index.ts @@ -11,9 +11,9 @@ * @module @deepseek-ai/dsh-acp-demo */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { join } from 'node:path' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import * as acp from '@deepseek-ai/dsh-acp' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import * as workspaceContext from '@deepseek-ai/dsh-workspace-context' diff --git a/packages/examples/acp-demo/src/invariant.ts b/packages/examples/acp-demo/src/invariant.ts index 95b57b57e1..106ba974d5 100644 --- a/packages/examples/acp-demo/src/invariant.ts +++ b/packages/examples/acp-demo/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp-demo' diff --git a/packages/examples/acp-demo/tests/acp-agent.spec.ts b/packages/examples/acp-demo/tests/acp-agent.spec.ts index 9c60bee081..af467660da 100644 --- a/packages/examples/acp-demo/tests/acp-agent.spec.ts +++ b/packages/examples/acp-demo/tests/acp-agent.spec.ts @@ -3,8 +3,8 @@ import { randomUUID } from 'node:crypto' import { mkdtemp } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { agentEvents } from '@deepseek-ai/dsh-agent' import { TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import type { Message } from '@deepseek-ai/dsh-llm' diff --git a/packages/examples/agent-spine-demo/README.i18n.yaml b/packages/examples/agent-spine-demo/README.i18n.yaml index 44de45d88f..87c1dc1562 100644 --- a/packages/examples/agent-spine-demo/README.i18n.yaml +++ b/packages/examples/agent-spine-demo/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/examples/agent-spine-demo/README.md -README.md: cf0dc2ecd6e51eb872be75dfe6d80a5338605195 -README.zh.md: e5a8672d494e0c456aa820641e685d00be624445 +README.md: 5957d9a8e9218e18d5d7d0f620b6be811f2c230f +README.zh.md: 78372240764ff3c779ea0805aedd26a00baf1768 diff --git a/packages/examples/agent-spine-demo/README.md b/packages/examples/agent-spine-demo/README.md index cf0dc2ecd6..5957d9a8e9 100644 --- a/packages/examples/agent-spine-demo/README.md +++ b/packages/examples/agent-spine-demo/README.md @@ -11,7 +11,7 @@ Read this package for the whole plugin tree and its composition order. `apply(ctx, config)` mounts each of these as a child of the bundle fiber: ``` -@cordisjs/plugin-timer timer service (writes nothing to stdout) +@deepseek-ai/cordis-plugin-timer timer service (writes nothing to stdout) @deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary @deepseek-ai/dsh-session event-sourced session log + store @deepseek-ai/dsh-session-title log-backed title service + deterministic fallback diff --git a/packages/examples/agent-spine-demo/README.zh.md b/packages/examples/agent-spine-demo/README.zh.md index e5a8672d49..7837224076 100644 --- a/packages/examples/agent-spine-demo/README.zh.md +++ b/packages/examples/agent-spine-demo/README.zh.md @@ -11,7 +11,7 @@ `apply(ctx, config)` 将以下每个插件挂载为组合包 fiber 的子节点: ``` -@cordisjs/plugin-timer timer service (writes nothing to stdout) +@deepseek-ai/cordis-plugin-timer timer service (writes nothing to stdout) @deepseek-ai/dsh-llm abstract LLM service + content-block vocabulary @deepseek-ai/dsh-session event-sourced session log + store @deepseek-ai/dsh-session-title log-backed title service + deterministic fallback diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index e6c6ce351d..2092965d75 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-agent-spine-demo", "description": "The default executor-less/UI-less agent spine with fallback session titles, provider-routed retry, and optional persisted goals", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/examples/agent-spine-demo" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,33 +32,33 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@cordisjs/plugin-timer": "^1.1.2", - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-agent-loop": "^0.0.1", - "@deepseek-ai/dsh-goal": "^0.0.1", - "@deepseek-ai/dsh-goal-session": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "@deepseek-ai/dsh-llm-retry": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-title": "^0.0.1", - "@deepseek-ai/dsh-skill": "^0.0.1", - "@deepseek-ai/dsh-skill-local": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tasks-local": "^0.0.1", - "@deepseek-ai/dsh-bash-env": "^0.0.1", - "@deepseek-ai/dsh-tool-bash": "^0.0.1", - "@deepseek-ai/dsh-tool-goal": "^0.0.1", - "@deepseek-ai/dsh-tool-skill": "^0.0.1", - "@deepseek-ai/dsh-tool-tasks": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-workspace-context": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-timer": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-loop": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-goal-session": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-llm-retry": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-skill-local": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks-local": "workspace:^", + "@deepseek-ai/dsh-bash-env": "workspace:^", + "@deepseek-ai/dsh-tool-bash": "workspace:^", + "@deepseek-ai/dsh-tool-goal": "workspace:^", + "@deepseek-ai/dsh-tool-skill": "workspace:^", + "@deepseek-ai/dsh-tool-tasks": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workspace-context": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-bash-env": "workspace:^", @@ -85,9 +92,9 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/node-addon-landlock-run": "workspace:*", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/examples/agent-spine-demo/src/index.ts b/packages/examples/agent-spine-demo/src/index.ts index 7d9e99a908..383fc8c212 100644 --- a/packages/examples/agent-spine-demo/src/index.ts +++ b/packages/examples/agent-spine-demo/src/index.ts @@ -8,9 +8,9 @@ * @module @deepseek-ai/dsh-agent-spine-demo */ -import type { Context } from 'cordis' -import Timer from '@cordisjs/plugin-timer' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import Timer from '@deepseek-ai/cordis-plugin-timer' +import z from '@deepseek-ai/schemastery' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' import SessionTitleService, { type Config as SessionTitleConfig } from '@deepseek-ai/dsh-session-title' diff --git a/packages/examples/agent-spine-demo/src/invariant.ts b/packages/examples/agent-spine-demo/src/invariant.ts index fada985329..9735d8e74d 100644 --- a/packages/examples/agent-spine-demo/src/invariant.ts +++ b/packages/examples/agent-spine-demo/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-spine-demo' diff --git a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts index 8de047a79a..dd059793d5 100644 --- a/packages/examples/agent-spine-demo/tests/agent-core.spec.ts +++ b/packages/examples/agent-spine-demo/tests/agent-core.spec.ts @@ -2,8 +2,8 @@ import { describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' import { join, sep } from 'node:path' import { tmpdir } from 'node:os' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { renderPrompt, TOOL_ORDER_REST } from '@deepseek-ai/dsh-system-prompt' import * as agentCore from '../src/index.ts' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts b/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts index ae4966fd12..dfd3ff407f 100644 --- a/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts +++ b/packages/examples/agent-spine-demo/tests/gen-config-catalog.spec.ts @@ -43,7 +43,7 @@ export interface Config { describe('gen-config-catalog classification', () => { it('classifies an apply plugin with a config parameter and extracts the paste', () => { const entries = collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' export const inject = ['tools'] ${DOCUMENTED_CONFIG} /** Load. */ @@ -57,8 +57,8 @@ export function apply(ctx: Context, config: Config): void {} it('classifies a default service class, reading its constructor and static inject', () => { const entries = collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' ${DOCUMENTED_CONFIG} /** Fixture service. */ export default class Fix { @@ -110,7 +110,7 @@ export default class Fix { describe('gen-config-catalog config extraction guards', () => { it('hard-errors on a config field with no JSDoc prose', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' export interface Config { knob?: string } @@ -122,7 +122,7 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors on an undocumented field nested in a type literal', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' /** Fixture config. */ export interface Config { /** Entries. */ @@ -138,7 +138,7 @@ export function apply(ctx: Context, config: Config): void {} it('pastes a package-local type transitively and records external refs', () => { const entries = collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' import type { Mode } from './types.ts' import type { Remote } from '@fix/dep' /** Fixture config. */ @@ -162,7 +162,7 @@ export function apply(ctx: Context, config: Config): void {} it('pastes an enum referenced by the config type', () => { const entries = collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' /** Fixture mode. */ export enum Mode { A = 'a', @@ -185,7 +185,7 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors on a referenced type name that resolves nowhere', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' /** Fixture config. */ export interface Config { /** The ghost. */ @@ -199,7 +199,7 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors on a config type imported from another package', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' import type { Config } from '@fix/dep' /** Load. */ export function apply(ctx: Context, config: Config): void {} @@ -209,7 +209,7 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors when one name resolves to two different declarations across the closure', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' import type { A } from './a.ts' import type { B } from './b.ts' /** Fixture config. */ @@ -231,8 +231,8 @@ export function apply(ctx: Context, config: Config): void {} describe('gen-config-catalog schema cross-check', () => { it('accepts a chained schema whose keys all appear on the config type', () => { const entries = collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' ${DOCUMENTED_CONFIG} export const Config: z = z.object({ knob: z.string() }).default({}) /** Load. */ @@ -244,8 +244,8 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors on a schema key the config type does not declare', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' ${DOCUMENTED_CONFIG} export const Config: z = z.object({ knob: z.string(), hidden: z.number() }) /** Load. */ @@ -256,8 +256,8 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors on a NESTED schema key the config type does not declare', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' /** Fixture config. */ export interface Config { /** Entries. */ @@ -280,8 +280,8 @@ export function apply(ctx: Context, config: Config): void {} 'src/types.ts': '/** Shared options. */\nexport interface Opts {\n /** Model. */\n model?: string\n}\n', }) writePkg(root, 'group/one', '@fix/one', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Opts } from '@fix/dep' /** Fixture config. */ export interface Config { @@ -301,8 +301,8 @@ export function apply(ctx: Context, config: Config): void {} it('resolves nested keys through a Partial<> wrapper', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' /** Caps. */ export interface Caps { /** X. */ @@ -322,8 +322,8 @@ export function apply(ctx: Context, config: Config): void {} it('leaves a nested key under an external (unresolvable) type unreported', () => { expect(() => collectConfigCatalog(make({ - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { External } from 'some-external-pkg' /** Fixture config. */ export interface Config { @@ -340,8 +340,8 @@ export function apply(ctx: Context, config: Config): void {} it('folds an intersected workspace schema into the subset check', () => { const root = makeRoot() writePkg(root, 'group/leaf', '@fix/leaf', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' /** Leaf config. */ export interface Config { /** Leaf knob. */ @@ -355,8 +355,8 @@ export default class Leaf { `, }) writePkg(root, 'group/bundle', '@fix/bundle', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import Leaf from '@fix/leaf' /** Bundle config. */ export interface Config { @@ -375,8 +375,8 @@ export function apply(ctx: Context, config: Config): void {} it('resolves composed nested keys through an indexed-access forwarder', () => { const root = makeRoot() writePkg(root, 'group/leaf', '@fix/leaf', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' /** Leaf config. */ export interface Config { /** Agents. */ @@ -393,8 +393,8 @@ export default class Leaf { `, }) writePkg(root, 'group/bundle', '@fix/bundle', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import Leaf, { type Config as LeafConfig } from '@fix/leaf' /** Bundle config forwarding the leaf's agents list. */ export interface Config { @@ -412,8 +412,8 @@ export function apply(ctx: Context, config: Config): void {} it('hard-errors when an intersected schema key is missing from the bundle config type', () => { const root = makeRoot() writePkg(root, 'group/leaf', '@fix/leaf', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' /** Leaf config. */ export interface Config { /** Leaf knob. */ @@ -427,8 +427,8 @@ export default class Leaf { `, }) writePkg(root, 'group/bundle', '@fix/bundle', { - 'src/index.ts': `import type { Context } from 'cordis' -import z from 'schemastery' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import Leaf from '@fix/leaf' /** Bundle config that forgot to declare the forwarded field. */ export interface Config { @@ -448,7 +448,7 @@ describe('gen-config-catalog render', () => { it('renders sections, fences, and the terse classification lists', () => { const root = makeRoot() writePkg(root, 'group/one', '@fix/one', { - 'src/index.ts': `import type { Context } from 'cordis' + 'src/index.ts': `import type { Context } from '@deepseek-ai/cordis' ${DOCUMENTED_CONFIG} /** Load. */ export function apply(ctx: Context, config: Config): void {} diff --git a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts index 53722aa7e5..8769d91e1b 100644 --- a/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts +++ b/packages/examples/agent-spine-demo/tests/multi-project-sandbox.e2e.ts @@ -3,7 +3,7 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promis import { homedir } from 'node:os' import { basename, join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SandboxBashExecutor } from '@deepseek-ai/dsh-bash-sandbox' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' diff --git a/packages/examples/jsonrpc-demo/README.i18n.yaml b/packages/examples/jsonrpc-demo/README.i18n.yaml index c47938ce3b..ae8f55a8cd 100644 --- a/packages/examples/jsonrpc-demo/README.i18n.yaml +++ b/packages/examples/jsonrpc-demo/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/examples/jsonrpc-demo/README.md -README.md: fff8e78698cd3d6320606084ef5c533be7c52633 -README.zh.md: 75382b97ea1837cf1415e8a7f5004206596e168c +README.md: 40ced3ee1fe2d3eac69b82501d417130267d7634 +README.zh.md: 451bdf7428f8265750082af240418d4653bb8595 diff --git a/packages/examples/jsonrpc-demo/README.md b/packages/examples/jsonrpc-demo/README.md index fff8e78698..40ced3ee1f 100644 --- a/packages/examples/jsonrpc-demo/README.md +++ b/packages/examples/jsonrpc-demo/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../scaffold/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published bin is `dsh-jsonrpc-agent`, and `lib/bin.js` also ships as the `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) used by the Python SDK. +Bin-only app that boots an external `cordis.yml`; its [`jsonrpc`](../../scaffold/server/README.md) entry serves SDK clients over newline-delimited stdio. The config composes the spine, backends, and serving plugin. The published `dsh-jsonrpc-agent` bin resolves bare plugins from the configuration project. The Python SDK's `dsh-jsonrpc-agent-pkg` [single-executable runtime](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md) uses `lib/packaged-bin.js` instead: packaged bare plugins resolve from its closed runtime tree, while relative plugins remain configuration-relative. ## Config discovery diff --git a/packages/examples/jsonrpc-demo/README.zh.md b/packages/examples/jsonrpc-demo/README.zh.md index 75382b97ea..451bdf7428 100644 --- a/packages/examples/jsonrpc-demo/README.zh.md +++ b/packages/examples/jsonrpc-demo/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../scaffold/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 bin 名为 `dsh-jsonrpc-agent`,`lib/bin.js` 还会作为 Python SDK 使用的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)交付。 +只包含 bin 的应用,启动外部 `cordis.yml`;其 [`jsonrpc`](../../scaffold/server/README.md) 入口通过按换行分隔的 stdio 为 SDK 客户端提供服务。配置负责组合主干、后端和服务插件。发布的 `dsh-jsonrpc-agent` bin 从配置项目解析裸插件。Python SDK 的 `dsh-jsonrpc-agent-pkg` [单文件可执行运行时](../../../.agents/notes/implemented/architecture/2026-07-10-single-file-executable-sdk-runtime-distribution.md)改用 `lib/packaged-bin.js`:已打包的裸插件从封闭运行时包树解析,相对插件仍以配置目录为基准。 ## 配置发现 diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index 155660d190..2524d75378 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-jsonrpc-demo", "description": "Bin that boots an external Cordis config for the stdio JSON-RPC SDK runtime", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/examples/jsonrpc-demo" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -22,6 +29,10 @@ "types": "./lib/types/bin.d.ts", "default": "./lib/bin.js" }, + "./packaged-bin": { + "types": "./lib/types/packaged-bin.d.ts", + "default": "./lib/packaged-bin.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, @@ -29,6 +40,7 @@ "lib/index.js", "lib/invariant.js", "lib/bin.js", + "lib/packaged-bin.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", @@ -36,11 +48,11 @@ "@deepseek-ai/dsh-app-boot": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/examples/jsonrpc-demo/src/bin.ts b/packages/examples/jsonrpc-demo/src/bin.ts index ad17709efc..532c1ba1b9 100644 --- a/packages/examples/jsonrpc-demo/src/bin.ts +++ b/packages/examples/jsonrpc-demo/src/bin.ts @@ -1,52 +1,11 @@ #!/usr/bin/env node /** - * Boots an external `cordis.yml`; its `@deepseek-ai/dsh-jsonrpc` entry serves - * newline-delimited JSON-RPC on stdio. `$DSH_CORDIS_CONFIG` wins over `argv[2]`; - * empty or missing paths exit 1, with no default config or `DSH_SNAPSHOT` mode. - * App-boot owns env loading, Loader guards, and settled-tree startup. - * stdin EOF and SIGTERM dispose the root context and exit 0; SIGINT exits 130. - * Protocol `shutdown` belongs to the server plugin. Stdout is reserved for frames. + * Generic JSON-RPC agent bin. External configurations own their bare plugin + * packages; the packaged runtime uses `packaged-bin.ts` instead. * * @module @deepseek-ai/dsh-jsonrpc-demo/bin */ -import { existsSync } from 'node:fs' -import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' +import { runJsonrpcAgent } from './runner.ts' -const NAME = 'dsh-jsonrpc-agent' - -/* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */ -installFailLoud(NAME) -loadEnv(NAME) - -// Env wins over argv; empty values are absent. External config defines the deployment. -const fromEnv = process.env['DSH_CORDIS_CONFIG'] -const fromArgv = process.argv[2] -const requested = fromEnv !== undefined && fromEnv !== '' - ? fromEnv - : fromArgv !== undefined && fromArgv !== '' ? fromArgv : undefined -const configPath = requested === undefined ? undefined : resolveConfigPath(requested, undefined) -if (configPath === undefined || !existsSync(configPath)) { - process.stderr.write( - `usage: ${NAME} (or set DSH_CORDIS_CONFIG=, which wins); the config is required — there is no built-in fallback\n`, - ) - process.exit(1) -} - -const ctx = await boot(NAME, configPath) -let exiting = false - -async function disposeAndExit(code: number): Promise { - if (exiting) return - exiting = true - try { - await ctx.fiber.dispose() - } finally { - process.exit(code) - } -} - -process.stdin.on('end', () => { void disposeAndExit(0) }) -process.on('SIGTERM', () => { void disposeAndExit(0) }) -process.on('SIGINT', () => { void disposeAndExit(130) }) -/* v8 ignore stop */ +await runJsonrpcAgent() diff --git a/packages/examples/jsonrpc-demo/src/index.ts b/packages/examples/jsonrpc-demo/src/index.ts index d4a4017f62..eb85517431 100644 --- a/packages/examples/jsonrpc-demo/src/index.ts +++ b/packages/examples/jsonrpc-demo/src/index.ts @@ -1,7 +1,8 @@ /** - * Bin-only app package: `bin.ts` discovers an external `cordis.yml` and owns - * process exit. This module exports no composition plugin; the config chooses - * whether to load the {@link @deepseek-ai/dsh-jsonrpc} serving plugin. + * Bin-only app package: its generic and packaged entries discover an external + * `cordis.yml` and own process exit. This module exports no composition plugin; + * the config chooses whether to load the + * {@link @deepseek-ai/dsh-jsonrpc} serving plugin. * * @module @deepseek-ai/dsh-jsonrpc-demo */ diff --git a/packages/examples/jsonrpc-demo/src/invariant.ts b/packages/examples/jsonrpc-demo/src/invariant.ts index dd093a5418..9eb3eeb53a 100644 --- a/packages/examples/jsonrpc-demo/src/invariant.ts +++ b/packages/examples/jsonrpc-demo/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc-demo' diff --git a/packages/examples/jsonrpc-demo/src/packaged-bin.ts b/packages/examples/jsonrpc-demo/src/packaged-bin.ts new file mode 100644 index 0000000000..4ad41a2ee4 --- /dev/null +++ b/packages/examples/jsonrpc-demo/src/packaged-bin.ts @@ -0,0 +1,12 @@ +#!/usr/bin/env node +/** + * Closed-runtime JSON-RPC agent bin. Bare plugins resolve from the installed + * runtime closure while relative plugins remain configuration-relative. + * + * @module @deepseek-ai/dsh-jsonrpc-demo/packaged-bin + */ + +import { runJsonrpcAgent } from './runner.ts' + +/* v8 ignore next -- exercised through the built Python runtime carriers */ +await runJsonrpcAgent(import.meta.url) diff --git a/packages/examples/jsonrpc-demo/src/runner.ts b/packages/examples/jsonrpc-demo/src/runner.ts new file mode 100644 index 0000000000..25d17481e5 --- /dev/null +++ b/packages/examples/jsonrpc-demo/src/runner.ts @@ -0,0 +1,55 @@ +/** + * Shared process lifecycle for the generic and closed-runtime JSON-RPC bins. + * + * @module @deepseek-ai/dsh-jsonrpc-demo/runner + */ + +import { existsSync } from 'node:fs' +import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' + +/* v8 ignore start -- composition over tested app-boot/jsonrpc and executable acceptance paths */ +const NAME = 'dsh-jsonrpc-agent' + +/** + * Boot the explicitly selected external configuration and own process exit. + * @param bareModuleBaseUrl - optional installed-runtime base for bare plugins; + * omit it when the configuration project owns its plugin packages. + * @returns after process handlers are installed; process lifetime then belongs + * to stdin and signal events. + */ +export async function runJsonrpcAgent(bareModuleBaseUrl?: string): Promise { + installFailLoud(NAME) + loadEnv(NAME) + + // Env wins over argv; empty values are absent. External config defines the deployment. + const fromEnv = process.env['DSH_CORDIS_CONFIG'] + const fromArgv = process.argv[2] + const requested = fromEnv !== undefined && fromEnv !== '' + ? fromEnv + : fromArgv !== undefined && fromArgv !== '' ? fromArgv : undefined + const configPath = requested === undefined ? undefined : resolveConfigPath(requested, undefined) + if (configPath === undefined || !existsSync(configPath)) { + process.stderr.write( + `usage: ${NAME} (or set DSH_CORDIS_CONFIG=, which wins); the config is required — there is no built-in fallback\n`, + ) + process.exit(1) + } + + const ctx = await boot(NAME, configPath, undefined, undefined, bareModuleBaseUrl) + let exiting = false + + async function disposeAndExit(code: number): Promise { + if (exiting) return + exiting = true + try { + await ctx.fiber.dispose() + } finally { + process.exit(code) + } + } + + process.stdin.on('end', () => { void disposeAndExit(0) }) + process.on('SIGTERM', () => { void disposeAndExit(0) }) + process.on('SIGINT', () => { void disposeAndExit(130) }) +} +/* v8 ignore stop */ diff --git a/packages/examples/jsonrpc-demo/tsdown.config.ts b/packages/examples/jsonrpc-demo/tsdown.config.ts index a8864a84a9..3609ebbc93 100644 --- a/packages/examples/jsonrpc-demo/tsdown.config.ts +++ b/packages/examples/jsonrpc-demo/tsdown.config.ts @@ -1,15 +1,21 @@ import { defineConfig } from 'tsdown' -/** - * Build the doc-only module and CLI entry; `tsc -b` supplies declarations. - */ -export default defineConfig({ - entry: ['lib/types/index.js', 'lib/types/invariant.js', 'lib/types/bin.js'], - outDir: 'lib', - format: ['esm'], - platform: 'node', - target: 'es2024', - fixedExtension: false, - dts: false, - clean: false, -}) +/** Builds each published entry as a self-contained file admitted by the package whitelist. */ +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, + }, + { + entry: ['lib/types/packaged-bin.js'], outDir: 'lib', format: ['esm'], platform: 'node', target: 'es2024', + fixedExtension: false, outputOptions: { codeSplitting: false }, dts: false, clean: false, + }, +]) diff --git a/packages/experimental/AGENTS.md b/packages/experimental/AGENTS.md deleted file mode 100644 index ee6bf61598..0000000000 --- a/packages/experimental/AGENTS.md +++ /dev/null @@ -1,11 +0,0 @@ -# AGENTS.md — Experimental and internal packages - -These rules supplement the [package rules](../AGENTS.md). The [experimental and internal package group decision](../../.agents/notes/implemented/architecture/2026-07-28-experimental-plugin-package-group.md) owns the rationale. - -- All Cordis plugin packages whose full public contract is experimental or internal-only belong here. An experimental option inside an otherwise stable package stays in that package's product-role group. -- Use this directory to share engineering and product-manager prototypes across the team so others can discover, run, review, and extend them against the real plugin graph. -- Official releases exclude this directory. A package enters a release only after moving to its product-role group; do not add packages here to release manifests or bundles. -- Experimental packages carry no stability, compatibility, migration, or support promise. Internal-only packages may define contracts for a limited set of internal callers and callees but make no public release promise. -- Experimental or internal-only status never relaxes repository engineering, security, documentation, lifecycle, testing, or snapshot requirements. -- Release packages must not take runtime dependencies on packages here. Examples may; every other runtime dependent is also experimental or internal-only and belongs here. Tests may use them as development dependencies. -- Promotion moves a package to its product-role group without renaming its `@deepseek-ai/dsh-*` package. Require explicit review of its public contract, limitations, test evidence, and a named owner accepting stable-package obligations. diff --git a/packages/experimental/README.md b/packages/experimental/README.md deleted file mode 100644 index db39af8bb1..0000000000 --- a/packages/experimental/README.md +++ /dev/null @@ -1,7 +0,0 @@ -# experimental/ — experimental and internal packages - -English | [中文](README.zh.md) - -This group hosts team-shared engineering and product-manager prototypes plus internal-only Cordis plugins. It is excluded from official releases; packages move to their product-role group before release. - -No packages live here yet. The [subtree rules](AGENTS.md) define the no-warranty, dependency, and promotion boundaries. diff --git a/packages/experimental/README.zh.md b/packages/experimental/README.zh.md deleted file mode 100644 index fc5942190a..0000000000 --- a/packages/experimental/README.zh.md +++ /dev/null @@ -1,7 +0,0 @@ -# experimental/:实验性与内部专用包 - -[English](README.md) | 中文 - -该分组容纳工程人员与产品经理在团队内共享的原型,以及内部专用 Cordis 插件。该分组不纳入官方发布版本;包在发布前移入对应的产品角色分组。 - -该分组尚未包含任何包。[子树规则](AGENTS.md)界定不作保证、依赖关系和提升机制的边界。 diff --git a/packages/feedback/README.i18n.yaml b/packages/feedback/README.i18n.yaml index fce2946cff..bab1cf0db2 100644 --- a/packages/feedback/README.i18n.yaml +++ b/packages/feedback/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/feedback/README.md -README.md: af8e9d5c4903594299284d09f880aa8929f5e051 -README.zh.md: 9156ff1c8da8ed1128488fcacaf454425468163a +README.md: 152db65bd7ac179bb8d475d446535734b9f8238e +README.zh.md: 64202a3c4a0258f9b41a6cd96e7bbbf011d0338d diff --git a/packages/feedback/README.md b/packages/feedback/README.md index af8e9d5c49..152db65bd7 100644 --- a/packages/feedback/README.md +++ b/packages/feedback/README.md @@ -2,10 +2,13 @@ English | [中文](README.zh.md) -The feedback family lets a human record a remark about the session without acting on it. Feedback is durable session-log content, separate from the model conversation and from any policy that might later read it. +The feedback family exposes two deliberately separate contracts: an immutable remark in the canonical Session log, and editable feedback attached to one assistant message in a local sidecar. Neither form enters the model conversation. | Package | Role | ctx key | |---|---|---| | `command-feedback/` | Trigger-independent `feedback/record` event plus the human-facing `/feedback` producer | — | +| `message-feedback/` | Lifecycle-bound per-message rating/note sidecar plus Host `messageFeedback.list/put/delete` Remote contract | `messageFeedback` | -A recorded remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../session/session-telemetry-otel) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy. +A command feedback remark is log-only: it never enters the model surface or derived history. When mounted, [`dsh-session-telemetry-otel`](../session/session-telemetry-otel) observes `feedback/record` to release a pending telemetry prefix or warn that disabled telemetry leaves the feedback local; capture itself remains independent of that policy. + +Message feedback is not a Session event or projection. It remains in the storage-domain sidecar and causes no telemetry handoff. The Host Remote contract ships with the service; the client Remote aggregate mount and UI consumer are separately owned and deferred. diff --git a/packages/feedback/README.zh.md b/packages/feedback/README.zh.md index 9156ff1c8d..64202a3c4a 100644 --- a/packages/feedback/README.zh.md +++ b/packages/feedback/README.zh.md @@ -2,10 +2,13 @@ [English](README.md) | 中文 -反馈家族让人类记录对会话的评价,但不据此采取任何动作。反馈属于持久的会话日志内容,与模型对话以及后续可能读取它的任何策略相互独立。 +反馈家族公开两份刻意分离的契约:写入权威 Session 日志的不可变评价,以及挂在单条 assistant 消息上的可编辑本地伴随记录(sidecar)反馈。两者都不会进入模型对话。 | 包 | 职责 | ctx 键 | |---|---|---| | `command-feedback/` | 与触发方式无关的 `feedback/record` 事件,以及面向用户的 `/feedback` 生产方 | 无 | +| `message-feedback/` | 绑定生命周期的逐消息评分/备注伴随记录,以及 Host `messageFeedback.list/put/delete` Remote 契约 | `messageFeedback` | -被记录的评价仅写入日志:它绝不会进入模型接口或派生历史。挂载后,[`dsh-session-telemetry-otel`](../session/session-telemetry-otel) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。 +command feedback 评价仅写入日志:它绝不会进入模型接口或派生历史。挂载后,[`dsh-session-telemetry-otel`](../session/session-telemetry-otel) 会观察 `feedback/record`,以释放待处理的遥测前缀,或在遥测已禁用时警告反馈将留在本地;采集本身与该策略相互独立。 + +message feedback 不是 Session 事件或投影。它只保留在 storage-domain 伴随记录中,不触发任何遥测交接。服务随附 Host Remote 契约;客户端 Remote 聚合挂载与 UI 消费方由各自边界负责,并保持延后。 diff --git a/packages/feedback/command-feedback/README.i18n.yaml b/packages/feedback/command-feedback/README.i18n.yaml index ea0c591ae2..fe49d8e490 100644 --- a/packages/feedback/command-feedback/README.i18n.yaml +++ b/packages/feedback/command-feedback/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/feedback/command-feedback/README.md -README.md: 52b8fb6a423fca69f76397deec36ecd22a6a6023 -README.zh.md: ca74d53f2531a46c2c16aa1423cee52e89c8256f +README.md: 24a975476b6783b439d4ec94c449f2acbe0b432f +README.zh.md: 12a4dcace001442351916b17fca0d7e2f2c76245 diff --git a/packages/feedback/command-feedback/README.md b/packages/feedback/command-feedback/README.md index 52b8fb6a42..24a975476b 100644 --- a/packages/feedback/command-feedback/README.md +++ b/packages/feedback/command-feedback/README.md @@ -8,11 +8,24 @@ Trigger-independent session feedback plus human-facing `/feedback` capture. The | Input | Result | |---|---| -| `/feedback ` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}` followed by `User: {userId}`. | +| `/feedback ` | Append `feedback/record` and acknowledge with `Feedback recorded for session {sessionId}`, `User: {userId}`, plus the session-sharing disclosure. | | `/feedback` | Return a direct usage error. Whitespace-only input is treated as empty. | Surrounding whitespace is discarded, but feedback is otherwise unparsed: no truncation, case folding, or control words. Text that looks like another command, such as `/feedback /plan felt slow`, is feedback content. Repeated commands each produce their own event; nothing is replaced or merged. +## Session-sharing disclosure + +The acknowledgement names the receiving session id and reports how that session is shared, read from the mounted [`telemetry`](../../session/session-telemetry/README.md) service through the plugin context (`ctx.get('telemetry')`, never a declared injection). The disclosure is one sentence chosen from the backend's [`TelemetrySharingStatus`](../../session/session-telemetry/README.md): + +| Disclosed status | Acknowledgement sentence | +|---|---| +| `full` | `Session sharing is enabled.` | +| `feedback-only` | `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.` | +| `disabled` | `Session sharing is disabled.` | +| no service | `Session sharing is not configured.` | + +The disclosure states the deployment's current sharing policy only; it never promises delivery or retention. With `full` or `feedback-only`, records are handed to the backend's non-blocking enqueue and the SDK owns batching, retry, and loss policy, so the sentence claims nothing about what reached a collector; `disabled` claims nothing about future reconfiguration. The disclosure adds no event and never enters the model surface. + ## What this plugin does and does not do `recordFeedback(session, text)` is the command-independent write path. It rejects empty normalized text and appends `feedback/record { text }`; a different UI, hook, or host integration can call it without constructing a slash command. The `/feedback` handler uses that producer and starts no model work. The optional [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) consumer observes the event without changing its capture contract. @@ -56,4 +69,5 @@ Independent of the model request path. Recording appends to the session log only - **No structured fields** — an entry is one free-text string with no category, severity, or referenced-event link, so feedback cannot be filtered by subject without re-reading its text. - **No amend or withdraw** — the session log is append-only and this package adds no tombstone, so a mistaken entry stays recorded and can only be superseded by a later one. - **No explicit durability barrier** — the acknowledgement follows the append, not a flush, so an entry recorded immediately before a crash can be lost with any other unflushed tail. Feedback is not worth forcing a synchronous disk write for; a consumer that needs one awaits `ctx.sessions.flush(session)`. +- **No visible acknowledgement on a fresh session** — the web transcript renders command rows only once a session is active, so `/feedback` on a still-blank session records the event but shows no acknowledgement row. Recording feedback after the first message renders normally. - **Web only among the shipped entry points** — headless mode, ACP automation, and JSON-RPC do not provide a command adapter, so `/feedback` is unavailable there. diff --git a/packages/feedback/command-feedback/README.zh.md b/packages/feedback/command-feedback/README.zh.md index ca74d53f25..12a4dcace0 100644 --- a/packages/feedback/command-feedback/README.zh.md +++ b/packages/feedback/command-feedback/README.zh.md @@ -8,11 +8,24 @@ | 输入 | 结果 | |---|---| -| `/feedback ` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}` 确认,随后显示 `User: {userId}`。 | +| `/feedback ` | 追加 `feedback/record`,并以 `Feedback recorded for session {sessionId}`、`User: {userId}` 加会话共享披露确认。 | | `/feedback` | 返回一个直接用法错误。仅含空白的输入视为空输入。 | 前后空白会被丢弃,但除此之外,反馈内容不会被解析:没有截断、大小写折叠或控制词。看起来像另一个命令的文本(例如 `/feedback /plan felt slow`)就是反馈内容。重复执行命令时,每次都会产生一个事件;不会发生替换或合并。 +## 会话共享披露 + +确认文本会点名接收会话的 id,并报告该会话如何被共享;该信息通过插件上下文(`ctx.get('telemetry')`,绝不是声明的注入)从已挂载的 [`telemetry`](../../session/session-telemetry/README.md) 服务读取。披露是依据后端 [`TelemetrySharingStatus`](../../session/session-telemetry/README.md) 选择的一句话: + +| 披露的状态 | 确认文本中的句子 | +|---|---| +| `full` | `Session sharing is enabled.` | +| `feedback-only` | `Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.` | +| `disabled` | `Session sharing is disabled.` | +| 无服务 | `Session sharing is not configured.` | + +披露只陈述部署当前的共享策略,绝不承诺投递或留存:在 `full` 或 `feedback-only` 下,记录被交给后端的非阻塞入队,批处理、重试与丢失策略归 SDK 负责,因此句子不声称任何内容已到达采集端;`disabled` 也不声称未来不会重新配置。披露不新增任何事件,也绝不会进入模型 surface。 + ## 本插件做什么、不做什么 `recordFeedback(session, text)` 是不依赖命令的写入路径。它拒绝规范化后为空的文本,并追加 `feedback/record { text }`;其他 UI、钩子或 host 集成无需构造斜杠命令即可调用它。`/feedback` 处理器通过该生产方写入,且不启动任何模型工作。可选的 [`dsh-session-telemetry-otel`](../../session/session-telemetry-otel) 消费方会观察该事件,但不改变它的采集约定。 @@ -56,4 +69,5 @@ - **没有结构化字段**:一条条目就是一个自由文本字符串,没有类别、严重程度或关联事件链接,因此无法在不重读文本的情况下按主题过滤反馈。 - **不支持修改或撤回**:会话日志是仅追加的,本包也不新增 tombstone,因此错误的条目会一直保留在记录中,只能由后续条目取代。 - **没有显式持久化屏障**:确认文本紧随追加而非 flush,因此紧临崩溃前记录的条目可能与其他未 flush 的尾部一同丢失。为反馈强制同步写盘并不值得;需要该保证的消费方可自行等待 `ctx.sessions.flush(session)`。 +- **新会话上没有可见的确认**:Web 转录只在会话激活后渲染命令行,因此在仍为空白的新会话上执行 `/feedback` 会记录事件但不会显示确认行。发送首条消息后再记录反馈即可正常渲染。 - **随附的产品入口中只有 Web 使用此命令**:无头模式、ACP 自动化和 JSON-RPC 不提供命令适配器,因此 `/feedback` 在那里不可用。 diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index 433087eff3..f45d504814 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback producer and human-facing slash command", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/feedback/command-feedback" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,21 +32,23 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-commands": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-user-id": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-telemetry": "workspace:^", + "@deepseek-ai/dsh-user-id": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-user-id": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/feedback/command-feedback/src/index.ts b/packages/feedback/command-feedback/src/index.ts index 7f0bb3a59f..8922df008e 100644 --- a/packages/feedback/command-feedback/src/index.ts +++ b/packages/feedback/command-feedback/src/index.ts @@ -6,8 +6,9 @@ * @module @deepseek-ai/dsh-command-feedback */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' +import type { Telemetry, TelemetrySharingStatus } from '@deepseek-ai/dsh-session-telemetry' import type { Session } from '@deepseek-ai/dsh-session' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' @@ -16,6 +17,42 @@ export const inject = ['commands'] const USAGE = 'Usage: /feedback ' +/** Fail closed when a future sharing status reaches the sentence switch. */ +/* v8 ignore next 3 -- only the ignored default arm calls this; the closed union cannot reach it via the public API. */ +function assertNever(value: never): never { + throw new Error(`command-feedback: unsupported sharing status ${JSON.stringify(value)}`) +} + +/** The acknowledgement's sharing sentence for a disclosed policy. */ +function sharingSentence(sharing: TelemetrySharingStatus): string { + switch (sharing) { + case 'full': + return 'Session sharing is enabled.' + case 'feedback-only': + return 'Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.' + case 'disabled': + return 'Session sharing is disabled.' + /* v8 ignore next 2 -- the seam's closed union cannot reach the default; a future status must be given a sentence here. */ + default: + return assertNever(sharing) + } +} + +/** + * The sharing disclosure appended to the acknowledgement: the mounted + * backend's disclosed policy, or a "not configured" notice when no backend + * is mounted. Read through the plugin context so the command still works + * when the telemetry service is absent. + * @param telemetry - the mounted telemetry service, or undefined. + * @returns one sentence describing this session's sharing policy. + */ +function sharingDisclosure(telemetry: Telemetry | undefined): string { + if (telemetry === undefined) { + return 'Session sharing is not configured.' + } + return sharingSentence(telemetry.sharing) +} + declare module '@deepseek-ai/dsh-session/types' { interface SessionEventMap { /** @@ -42,17 +79,20 @@ export function recordFeedback(session: Session, text: string): void { * Validate, record, and acknowledge one feedback entry. Returning an error * leaves no `feedback/record` event. * @param invocation - receiving agent, raw command input, and UI cancellation. + * @param ctx - plugin context used to read the optional telemetry service. * @returns an acknowledgement containing the receiving session and anonymous - * user ids, or a usage error when no feedback text was supplied. + * user ids plus the session-sharing disclosure, or a usage error when no + * feedback text was supplied. */ -function executeFeedbackCommand(invocation: CommandInvocation): CommandResult { +function executeFeedbackCommand(invocation: CommandInvocation, ctx: Context): CommandResult { if (invocation.rawInput.trim().length === 0) { return { kind: 'error', text: `Feedback text is required. ${USAGE}` } } recordFeedback(invocation.agent.session, invocation.rawInput) + const telemetry = ctx.get('telemetry') return { kind: 'success', - text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}`, + text: `Feedback recorded for session ${invocation.agent.session.id}\nUser: ${getOrCreateAnonymousUserId()}. ${sharingDisclosure(telemetry)}`, } } @@ -63,6 +103,6 @@ export function apply(ctx: Context): void { description: 'record feedback about this session', input: { hint: '' }, recordInput: false, - handler: executeFeedbackCommand, + handler: invocation => executeFeedbackCommand(invocation, ctx), }) } diff --git a/packages/feedback/command-feedback/src/invariant.ts b/packages/feedback/command-feedback/src/invariant.ts index 9c825a6e87..6f4c420ec6 100644 --- a/packages/feedback/command-feedback/src/invariant.ts +++ b/packages/feedback/command-feedback/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-command-feedback' diff --git a/packages/feedback/command-feedback/tests/command-feedback.spec.ts b/packages/feedback/command-feedback/tests/command-feedback.spec.ts index 6f93ff854e..ca965bff0d 100644 --- a/packages/feedback/command-feedback/tests/command-feedback.spec.ts +++ b/packages/feedback/command-feedback/tests/command-feedback.spec.ts @@ -1,10 +1,11 @@ import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' import SessionStore, { foldSurface, Session, SessionId } from '@deepseek-ai/dsh-session' +import { Telemetry, type TelemetrySharingStatus } from '@deepseek-ai/dsh-session-telemetry' import * as commandFeedback from '@deepseek-ai/dsh-command-feedback' const { USER_ID, getOrCreateAnonymousUserId } = vi.hoisted(() => { @@ -25,6 +26,20 @@ interface Harness { readonly plugin: Awaited> } +/** Minimal mounted backend disclosing one sharing policy. */ +class FakeTelemetry extends Telemetry { + override readonly sharing: TelemetrySharingStatus + + constructor(ctx: Context, config: { sharing: TelemetrySharingStatus }) { + super(ctx) + this.sharing = config.sharing + } + + emit(): void {} + + async shutdown(): Promise {} +} + /** Build a live idle agent over a store-owned session, as an app's spine does. */ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } { const session = ctx.sessions.create(SessionId(id)) @@ -48,12 +63,17 @@ function stubAgent(ctx: Context, id: string): { agent: Agent; session: Session } return { agent, session } } -/** Mount the real command registry and this producer. */ -async function harness(): Promise { +/** + * Mount the real command registry, this producer, and optionally a telemetry + * backend disclosing one sharing policy. Without `sharing`, no telemetry + * service exists and the acknowledgement reports "not configured". + */ +async function harness(sharing?: TelemetrySharingStatus): Promise { const ctx = new Context() await ctx.plugin(CommandService) await ctx.plugin(AgentRegistry) await ctx.plugin(SessionStore) + if (sharing !== undefined) await ctx.plugin(FakeTelemetry, { sharing }) const plugin = await ctx.plugin(commandFeedback) const { agent, session } = stubAgent(ctx, `command-feedback-${Math.random()}`) ctx.agents.register(agent) @@ -104,7 +124,7 @@ describe('/feedback human command', () => { const test = await harness() await expect(run(test, ' the diff view is unreadable')).resolves.toEqual({ kind: 'success', - text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}`, + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.`, }) expect(feedbackTexts(test.session)).toEqual(['the diff view is unreadable']) const commandRun = test.session.events.find(event => event.type === 'command/run') @@ -152,12 +172,39 @@ describe('/feedback human command', () => { test.ctx.commands.execute(test.agent, '/feedback second', signal), ]) expect(settled.map(item => item?.result)).toEqual([ - { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` }, - { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}` }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.` }, + { kind: 'success', text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is not configured.` }, ]) expect(feedbackTexts(test.session)).toEqual(['first', 'second']) }) + it('discloses full session sharing in the acknowledgement', async () => { + const test = await harness('full') + await expect(run(test, ' everything shared')).resolves.toEqual({ + kind: 'success', + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is enabled.`, + }) + expect(feedbackTexts(test.session)).toEqual(['everything shared']) + }) + + it('discloses feedback-gated session sharing in the acknowledgement', async () => { + const test = await harness('feedback-only') + await expect(run(test, ' gated sharing')).resolves.toEqual({ + kind: 'success', + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is feedback-gated; recording feedback releases the session prefix for sharing.`, + }) + expect(feedbackTexts(test.session)).toEqual(['gated sharing']) + }) + + it('discloses disabled session sharing in the acknowledgement', async () => { + const test = await harness('disabled') + await expect(run(test, ' local only')).resolves.toEqual({ + kind: 'success', + text: `Feedback recorded for session ${test.session.id}\nUser: ${USER_ID}. Session sharing is disabled.`, + }) + expect(feedbackTexts(test.session)).toEqual(['local only']) + }) + it('keeps every recorded event off the model surface and out of derived history', async () => { const test = await harness() await run(test, ' invisible to the model') diff --git a/packages/feedback/command-feedback/tests/loader-composition.spec.ts b/packages/feedback/command-feedback/tests/loader-composition.spec.ts index 958b23736f..e777f13124 100644 --- a/packages/feedback/command-feedback/tests/loader-composition.spec.ts +++ b/packages/feedback/command-feedback/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' @@ -93,7 +93,7 @@ describe('/feedback real Loader composition through cordis.yml', () => { const userId = getOrCreateAnonymousUserId({ env: { DSH_HOME: root } }) expect(accepted?.result).toEqual({ kind: 'success', - text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}`, + text: `Feedback recorded for session feedback-loader-agent\nUser: ${userId}. Session sharing is not configured.`, }) const rejected = await context.commands.execute(owner, '/feedback', signal) expect(rejected?.result).toEqual({ diff --git a/packages/feedback/command-feedback/tsconfig.json b/packages/feedback/command-feedback/tsconfig.json index c39f55f60f..fe189a9c3e 100644 --- a/packages/feedback/command-feedback/tsconfig.json +++ b/packages/feedback/command-feedback/tsconfig.json @@ -23,6 +23,9 @@ { "path": "../../session/user-id" }, + { + "path": "../../session/session-telemetry" + }, { "path": "../../support/invariants" } diff --git a/packages/feedback/message-feedback/README.i18n.yaml b/packages/feedback/message-feedback/README.i18n.yaml new file mode 100644 index 0000000000..edf8ec947b --- /dev/null +++ b/packages/feedback/message-feedback/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/feedback/message-feedback/README.md +README.md: a9ebad25907e32435c8d2a65eb4b2cd9eaa89eeb +README.zh.md: 29cbee0c1ec2ee810948d895c762d2e6320e9b66 diff --git a/packages/feedback/message-feedback/README.md b/packages/feedback/message-feedback/README.md new file mode 100644 index 0000000000..a9ebad2590 --- /dev/null +++ b/packages/feedback/message-feedback/README.md @@ -0,0 +1,84 @@ +# @deepseek-ai/dsh-message-feedback + +English | [中文](README.zh.md) + +Host-owned editable feedback for one finalized assistant message. The package registers `ctx.messageFeedback`, persists one lifecycle-bound sidecar row per Session in storage-domain, and publishes the Host `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete` unary Remote contract. It is separate from the immutable Session-level `feedback/record` event and performs no telemetry handoff. The [message-feedback sidecar Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md) owns the design boundary. + +Public request, value, version, and failure types are exported from the package root and `@deepseek-ai/dsh-message-feedback/types`; [`src/types.ts`](src/types.ts) is their source. + +## Configuration + +| key | meaning | +|---|---| +| `maxNoteBytes` | Required positive safe integer: maximum UTF-8 byte length of one optional note. | + +Notes must contain at least one non-whitespace character, but accepted text is stored verbatim rather than trimmed. Omitting `note` means the desired value has no note, so a version-matched material `put` clears an existing note. Note validation precedes Session lookup and can therefore return `note-blank` or `note-too-large` for a missing Session without touching persistence. + +```yaml +- id: message-feedback + name: '@deepseek-ai/dsh-message-feedback' + config: + maxNoteBytes: 8192 +``` + +The service injects `storageDomain`, `sessionPersistence`, and `sessions`. Its durable domain is `message_feedback`, with one `sessions` table row per `SessionId`. + +## Data, lifecycle, and durability + +`MessageFeedbackItem` contains `messageId`, `rating: 'positive' | 'negative'`, optional `note`, an opaque equality-only `version`, and Host-assigned `createdAt`/`updatedAt` Unix-millisecond timestamps. A material update preserves `createdAt`, replaces `version`, and keeps `updatedAt` from moving backward. `list` returns fresh immutable snapshots in first-creation order; updating an item retains its place, while deleting and later recreating it appends a new item. + +Each stored row carries the inspected Session header identity `{createdAt, cwd}`. A mismatch is treated as absence: `list` returns an empty `items` array, `delete` returns the absent postcondition, and `put` may replace the stale row with one bound to the current identity. This fences a reused `SessionId` when its header identity differs. Forks use a distinct Session identity and receive no feedback-row copy. + +`SessionPersistence.inspect()` supplies a cold-safe observation without publishing or resuming an Agent and without committing cold repair. For a Session without a live owner, `listSnapshots()` first decides definite absence; an `inspect()` failure for a catalogued Session remains an infrastructure failure rather than being guessed into `session-not-found`. `put` accepts only a non-empty, append-origin `assistant/message` with the requested `MessageId`; replacement-origin messages, empty usage-only assistant records, and non-assistant records return `target-not-found`. + +After initial validation, `put` establishes a durability barrier before writing the sidecar. A matching live Session commits through the canonical `ctx.sessions.flush` checkpoint, then both live and cold paths are physically read from sequence zero through `SessionPersistence.readFrom`. The resulting observation's header identity and target are validated again. A missing flush participant, changed identity, vanished target, or physical-read failure prevents the sidecar commit, so durable feedback never precedes the durable target message. + +Message feedback is not Session-log content or a Session projection. It emits no `feedback/record` event, does not enter model history, and does not trigger `FEEDBACK_ONLY` telemetry release. + +## Service and Host Remote contract + +The same three `MessageFeedbackService` methods are published by `GatewayService` and `@Remote`; the Host endpoint names are `messageFeedback.list`, `messageFeedback.put`, and `messageFeedback.delete`. Every method returns a discriminated business union: `{ ok: true, value }` or `{ ok: false, error }`. Operational storage, corruption, or missing-durability-listener failures reject instead of being mislabeled as business errors. + +| Method | Request | Success `value` | Rejected `error.code` | +|---|---|---|---| +| `list` | `MessageFeedbackListRequest { sessionId }` | `MessageFeedbackListValue { items }` | `session-not-found` | +| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | committed `MessageFeedbackItem` | `session-not-found`, `target-not-found`, `version-conflict`, `note-blank`, `note-too-large` | +| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`, `version-conflict` | + +`MessageFeedbackVersionConflict` returns the authoritative `current` item, or `null` when no item exists. This lets a caller reconcile the current rating, note, and version without a second `list` request. `MessageFeedbackNoteTooLarge` returns both `maxBytes` and `actualBytes`. The Client Remote aggregate does not mount the generated client contribution yet; Host callers can use the service/Remote contract without that client assembly. + +## Compare-and-set and idempotency + +`ifVersion: null` requests creation only; every request for an existing item requires its exact current version, including a no-op whose desired value already matches. The check is per message rather than per Session, so changing one item does not conflict with another. Every material create or update assigns a fresh opaque UUID token, preventing stale writes from crossing an ABA value cycle. + +A matching-version no-op returns the already stored item with unchanged version and timestamps. After a lost success response, a retry with the old token receives `version-conflict.current`; the caller can compare that authoritative item with its desired value without an extra read. `delete` ignores `ifVersion` when the item is already absent and always returns the stable `{ absent: true }` postcondition after success. + +A per-Session promise queue encloses inspection, durability validation, sidecar read, comparison, and whole-row write. These semantics serialize concurrent mutations through one service instance; storage-domain itself has no cross-process conditional write. + +Plugin disposal closes mutation admission, drains every operation already accepted into the per-Session queues, and only then closes the storage domain. A mutation submitted after disposal begins rejects as a lifecycle failure instead of entering a closing domain. + +## Model Experience + +### Local message-feedback state + +#### What the model sees + +Nothing. `ctx.messageFeedback` registers no tool, prompt section, model-facing context, or Session event; feedback stays in a Host-owned sidecar unless a separately documented Consumer explicitly exposes it. + +#### Token effect + +Zero. No request, result, rating, note, timestamp, or failure from this package enters a model request. + +#### KV Cache effect + +Independent. Listing or mutating message feedback does not touch a model request prefix and cannot invalidate an otherwise reusable provider cache entry. + +## Known Limitations and Deferred Work + +- **Client aggregate and UI are absent** — the Host Remote contract ships, but the Client Remote aggregate contribution and any UI consumer are separately owned and deferred. +- **Compare-and-set is single-process** — the per-Session queue serializes one service instance only; multiple Host processes writing one storage root can still lose updates because storage-domain exposes no cross-process conditional write. +- **No durable Session deletion cascade** — Session persistence has no deletion surface, and `session/disposed`/`host/session-removed` mean detach rather than durable deletion. The service therefore retains empty rows and may leave orphan rows after out-of-band log removal instead of deleting valid feedback on detach. +- **Detach/catalog retirement window** — a request in the narrow interval after live detach but before the persistence catalog materializes the header can receive `session-not-found`; callers retry after retirement materialization. +- **Header identity is not a content fingerprint** — `{createdAt, cwd}` detects reuse only when those fields differ; a cloned log retaining the same header identity is indistinguishable. +- **Trusted caller boundary** — `list`/`put`/`delete` carry no authenticated actor or audit identity. A deployment must expose the Host gateway only through its trusted or separately authenticated boundary until authorization and attribution are added. +- **Catalog and row bounds** — a cold request scans the complete Session snapshot catalog because persistence has no lookup-by-id metadata operation. `maxNoteBytes` bounds one note, but the item count and aggregate retained bytes of one Session row are not capped; an indexed metadata read and deployment-owned row bound remain deferred until a concrete consumer defines their policy. diff --git a/packages/feedback/message-feedback/README.zh.md b/packages/feedback/message-feedback/README.zh.md new file mode 100644 index 0000000000..29cbee0c1e --- /dev/null +++ b/packages/feedback/message-feedback/README.zh.md @@ -0,0 +1,84 @@ +# @deepseek-ai/dsh-message-feedback + +[English](README.md) | 中文 + +本包提供由 Host 拥有、针对单条已完成 assistant 消息的可编辑反馈。它注册 `ctx.messageFeedback`,在 storage-domain 中为每个 Session 持久化一条绑定生命周期的伴随记录(sidecar),并发布 Host `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete` 一元 Remote 契约。它与不可变的 Session 级 `feedback/record` 事件相互独立,不执行遥测交接。[消息反馈伴随记录 Agent Note](../../../.agents/notes/implemented/architecture/2026-08-10-message-feedback-sidecar.md)拥有其设计边界。 + +公开的请求、值、版本与失败类型从包根入口及 `@deepseek-ai/dsh-message-feedback/types` 导出;其源码为 [`src/types.ts`](src/types.ts)。 + +## 配置 + +| 键 | 含义 | +|---|---| +| `maxNoteBytes` | 必填正 safe integer:一条可选备注的最大 UTF-8 字节长度。 | + +备注必须包含至少一个非空白字符,但通过校验的文本按原样存储,不会 trim。省略 `note` 表示目标值不含备注,因此 version 匹配的实质 `put` 会清除已有备注。备注校验早于 Session 查找,因此即使 Session 不存在,也可能在不访问持久化的情况下返回 `note-blank` 或 `note-too-large`。 + +```yaml +- id: message-feedback + name: '@deepseek-ai/dsh-message-feedback' + config: + maxNoteBytes: 8192 +``` + +服务注入 `storageDomain`、`sessionPersistence` 与 `sessions`。其持久存储域为 `message_feedback`,其中 `sessions` 表按 `SessionId` 每个一行。 + +## 数据、生命周期与持久性 + +`MessageFeedbackItem` 包含 `messageId`、`rating: 'positive' | 'negative'`、可选 `note`、只能做相等比较的 opaque `version`,以及由 Host 分配、以 Unix 毫秒表示的 `createdAt`/`updatedAt` 时间戳。实质更新保留 `createdAt`、替换 `version`,并保证 `updatedAt` 不倒退。`list` 按首次创建顺序返回新的不可变快照;更新条目时保留其位置,删除后再创建则追加为新条目。 + +每条存储行都携带检查所得 Session header 身份 `{createdAt, cwd}`。不匹配按不存在处理:`list` 返回空 `items` 数组,`delete` 返回已不存在的后置条件,`put` 可以用绑定当前身份的新行替换陈旧行。这会在复用的 `SessionId` 具有不同 header 身份时形成隔离。fork 使用独立的 Session 身份,不复制反馈伴随记录。 + +`SessionPersistence.inspect()` 提供 cold-safe 观测,不发布或恢复 Agent,也不提交 cold repair。对于没有 live owner 的 Session,系统先用 `listSnapshots()` 判定明确不存在;已进入目录的 Session 若 `inspect()` 失败,仍属于基础设施故障,不会被猜测成 `session-not-found`。`put` 只接受具有指定 `MessageId` 的非空、append-origin `assistant/message`;replacement-origin 消息、仅承载 usage 的空 assistant 记录与非 assistant 记录都返回 `target-not-found`。 + +初步校验后,`put` 在写入伴随记录前建立 durability barrier。身份匹配的 live Session 先通过权威 `ctx.sessions.flush` checkpoint 提交,随后 live 与 cold 路径都会通过 `SessionPersistence.readFrom` 从序列零做物理复读。之后再次校验所得观测的 header 身份与目标。缺少 flush 参与方、身份变化、目标消失或物理读取失败都会阻止伴随记录提交,因此持久反馈绝不会先于其持久目标消息。 + +message feedback 不是 Session 日志内容或 Session 投影。它不发出 `feedback/record` 事件,不进入模型历史,也不触发 `FEEDBACK_ONLY` 遥测释放。 + +## 服务与 Host Remote 契约 + +`GatewayService` 与 `@Remote` 将 `MessageFeedbackService` 的同三个方法发布出去;Host endpoint 名称为 `messageFeedback.list`、`messageFeedback.put` 与 `messageFeedback.delete`。每个方法都返回判别式业务 union:`{ ok: true, value }` 或 `{ ok: false, error }`。存储、损坏或缺少 durability listener 等操作故障会产生 reject,不会被误标为业务错误。 + +| 方法 | 请求 | 成功 `value` | 拒绝的 `error.code` | +|---|---|---|---| +| `list` | `MessageFeedbackListRequest { sessionId }` | `MessageFeedbackListValue { items }` | `session-not-found` | +| `put` | `MessageFeedbackPutRequest { sessionId, messageId, rating, note?, ifVersion }` | 已提交的 `MessageFeedbackItem` | `session-not-found`、`target-not-found`、`version-conflict`、`note-blank`、`note-too-large` | +| `delete` | `MessageFeedbackDeleteRequest { sessionId, messageId, ifVersion }` | `MessageFeedbackDeleteValue { absent: true }` | `session-not-found`、`version-conflict` | + +`MessageFeedbackVersionConflict` 返回权威 `current` 条目;条目不存在时为 `null`。调用方无需额外执行 `list`,即可协调当前 rating、note 与 version。`MessageFeedbackNoteTooLarge` 同时返回 `maxBytes` 与 `actualBytes`。客户端 Remote 聚合尚未挂载生成的客户端 contribution;Host 调用方无需该客户端组装即可使用 service/Remote 契约。 + +## Compare-and-set 与幂等性 + +`ifVersion: null` 表示仅当条目不存在时才创建;已有条目的每次请求都必须与其当前 version 完全一致,即使目标值已经相同、不会产生实质更新。检查按消息而非按 Session 进行,因此修改一个条目不会与另一个条目冲突。每次实质创建或更新都会分配新的 opaque UUID token,防止陈旧写入穿过 ABA 值循环。 + +携带匹配 version 的无变化请求会返回已存条目,version 与时间戳均不变。成功响应丢失后,使用旧 token 重试会得到 `version-conflict.current`;调用方无需额外读取,即可把权威当前值与目标值比较。条目已不存在时,`delete` 忽略 `ifVersion`;成功后始终返回稳定的 `{ absent: true }` 后置条件。 + +按 Session 划分的 promise 队列覆盖检查、持久性校验、伴随记录读取、比较与整行写入。这些语义会串行化经由同一服务实例的并发变更;storage-domain 自身没有跨进程条件写。 + +Plugin disposal 会先关闭变更接纳,排空已进入各个 Session 队列的所有操作,然后才关闭 storage domain。disposal 开始后提交的变更会以生命周期故障拒绝,不会进入正在关闭的 domain。 + +## 模型体验 + +### 本地消息反馈状态 + +#### 模型看到的内容 + +无。`ctx.messageFeedback` 不注册工具、提示词段落、模型可见上下文或 Session 事件;除非另一个具有独立文档的 Consumer 显式公开反馈,否则它只留在 Host 拥有的伴随记录中。 + +#### Token 影响 + +为零。本包的请求、结果、评分、备注、时间戳或失败都不会进入模型请求。 + +#### KV Cache 影响 + +相互独立。读取或变更消息反馈不会触碰模型请求前缀,也不会使本可复用的提供方缓存条目失效。 + +## 已知局限与延后工作 + +- **缺少客户端聚合与 UI**——Host Remote 契约已经发布,但客户端 Remote 聚合 contribution 与任何 UI 消费方由各自边界负责并保持延后。 +- **Compare-and-set 仅限单进程**——按 Session 划分的队列只串行化一个服务实例;storage-domain 不提供跨进程条件写,因此多个 Host 进程写入同一存储根目录时仍可能丢失更新。 +- **没有持久 Session 删除级联**——Session persistence 没有删除接口,且 `session/disposed`/`host/session-removed` 表示 detach 而非持久删除。因此服务会保留空行,并可能在带外移除日志后留下孤儿行,而不会在 detach 时删除仍有效的反馈。 +- **Detach/catalog retirement 窗口**——请求若恰好落在 live detach 之后、persistence catalog 物化 header 之前的极短窗口,可能收到 `session-not-found`;调用方应在 retirement materialization 后重试。 +- **Header 身份不是内容指纹**——只有 `{createdAt, cwd}` 不同时才能识别复用;本契约无法区分保留相同 header 身份的克隆日志。 +- **调用方边界受信任**——`list`/`put`/`delete` 不携带已认证的 actor 或审计身份。在加入授权与归属信息前,部署方必须只通过受信任或另行认证的边界暴露 Host gateway。 +- **目录与行边界**——由于 persistence 没有按 id 读取元数据的操作,cold 请求会扫描完整的 Session snapshot 目录。`maxNoteBytes` 只限制单条备注,单个 Session 行的条目数和聚合保留字节尚无上限;按索引读取元数据和由部署决定的行边界,延后到具体消费方明确策略时处理。 diff --git a/packages/feedback/message-feedback/package.json b/packages/feedback/message-feedback/package.json new file mode 100644 index 0000000000..fe0caa18fb --- /dev/null +++ b/packages/feedback/message-feedback/package.json @@ -0,0 +1,82 @@ +{ + "name": "@deepseek-ai/dsh-message-feedback", + "description": "Lifecycle-bound per-message rating and note sidecar for the DeepSeek Harness", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/feedback/message-feedback" + }, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./types": { + "types": "./lib/types/types.d.ts", + "default": "./lib/types/types.js" + }, + "./typert": { + "types": "./lib/typert.host.d.ts", + "default": "./lib/typert.host.js" + }, + "./remote": { + "types": "./lib/typert.remote-client.d.ts", + "default": "./lib/typert.remote-client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "files": [ + "lib/index.js", + "lib/invariant.js", + "lib/types/**/*.js", + "lib/types/**/*.d.ts", + "lib/typert.host.js", + "lib/typert.host.d.ts", + "lib/typert.remote-client.js", + "lib/typert.remote-client.d.ts", + "lib/typert.remote-client.d.ts.map", + "src" + ], + "license": "BSD-3-Clause", + "peerDependencies": { + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^", + "zod": "^4.4.3" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-storage-json": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" + } +} diff --git a/packages/feedback/message-feedback/src/index.ts b/packages/feedback/message-feedback/src/index.ts new file mode 100644 index 0000000000..6126e837e1 --- /dev/null +++ b/packages/feedback/message-feedback/src/index.ts @@ -0,0 +1,383 @@ +/** + * Durable, lifecycle-bound feedback for finalized assistant messages. + * @module @deepseek-ai/dsh-message-feedback + */ + +import { Buffer } from 'node:buffer' +import { randomUUID } from 'node:crypto' +import { Context, Service } from '@deepseek-ai/cordis' +import s from '@deepseek-ai/schemastery' +import { deriveEventMessage, isAppendSurfaceEvent } from '@deepseek-ai/dsh-session/surface' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session/types' +import type { SessionInspection } from '@deepseek-ai/dsh-session-persistence' +import type { KvTable } from '@deepseek-ai/dsh-storage-domain' +import { GatewayService, Remote } from '@deepseek-ai/dsh-type-meta' +import { messageFeedbackDomainSpec } from './spec.ts' +import type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts' +import type { + MessageFeedbackDeleteRequest, + MessageFeedbackDeleteResult, + MessageFeedbackDeleteValue, + MessageFeedbackFailure, + MessageFeedbackItem, + MessageFeedbackListRequest, + MessageFeedbackListResult, + MessageFeedbackListValue, + MessageFeedbackNoteBlank, + MessageFeedbackNoteTooLarge, + MessageFeedbackPutRequest, + MessageFeedbackPutResult, + MessageFeedbackRejected, + MessageFeedbackSessionNotFound, + MessageFeedbackSuccess, + MessageFeedbackVersion, + MessageFeedbackVersionConflict, +} from './types.ts' + +export type * from './types.ts' +export { + messageFeedbackDomainSpec, + messageFeedbackItemSchema, + messageFeedbackRatingSchema, + messageFeedbackRowSchema, + messageFeedbackSessionIdentitySchema, + messageFeedbackVersionSchema, +} from './spec.ts' +export type { MessageFeedbackRow, MessageFeedbackSessionIdentity } from './spec.ts' + +/** Required deployment policy for optional notes. */ +export interface Config { + /** Maximum UTF-8 byte length accepted for one note. */ + readonly maxNoteBytes: number +} + +declare module '@deepseek-ai/cordis' { + interface Context { + messageFeedback: MessageFeedbackService + } +} + +/** Immutable empty list reused only as an input to caller-owned copying. */ +const EMPTY_ITEMS: readonly MessageFeedbackItem[] = Object.freeze([]) + +/** Validate the one deployment-varying limit at the configuration boundary. */ +function resolveMaxNoteBytes(value: number): number { + if (!Number.isSafeInteger(value) || value < 1) { + throw new TypeError( + `message-feedback: maxNoteBytes must be a positive safe integer, got ${String(value)}`, + ) + } + return value +} + +/** Copy and freeze one item before it crosses the service boundary. */ +function snapshotItem(item: MessageFeedbackItem): MessageFeedbackItem { + return Object.freeze({ + messageId: item.messageId, + rating: item.rating, + ...(item.note === undefined ? {} : { note: item.note }), + version: item.version, + createdAt: item.createdAt, + updatedAt: item.updatedAt, + }) +} + +/** Copy and freeze a list response. */ +function snapshotList(items: readonly MessageFeedbackItem[]): MessageFeedbackListValue { + return Object.freeze({ items: Object.freeze(items.map(snapshotItem)) }) +} + +/** Build a frozen success branch. */ +function success(value: T): MessageFeedbackSuccess { + return Object.freeze({ ok: true, value }) +} + +/** Build a frozen business-failure branch. */ +function rejected(error: E): MessageFeedbackRejected { + return Object.freeze({ ok: false, error: Object.freeze(error) }) +} + +/** Project the Session fields that distinguish one persisted log lifecycle. */ +function identityOf(header: SessionHeader): MessageFeedbackSessionIdentity { + return Object.freeze({ + createdAt: header.createdAt, + ...(header.cwd === undefined ? {} : { cwd: header.cwd }), + }) +} + +/** Whether a stored row belongs to the inspected Session lifecycle. */ +function sameIdentity(row: MessageFeedbackRow, header: SessionHeader): boolean { + return row.session.createdAt === header.createdAt && row.session.cwd === header.cwd +} + +/** Whether two observations name the same persisted Session lifecycle. */ +function sameHeaderIdentity(left: SessionHeader, right: SessionHeader): boolean { + return left.id === right.id && left.createdAt === right.createdAt && left.cwd === right.cwd +} + +/** Freeze the replacement row so storage-domain never exposes mutable aliases. */ +function rowSnapshot( + session: MessageFeedbackSessionIdentity, + items: readonly MessageFeedbackItem[], +): MessageFeedbackRow { + const copiedItems = items.map(snapshotItem) + Object.freeze(copiedItems) + return Object.freeze({ + session, + items: copiedItems, + }) +} + +/** Generate an opaque equality token for one material mutation. */ +function nextVersion(): MessageFeedbackVersion { + return randomUUID() as MessageFeedbackVersion +} + +/** Session inspection result that keeps absence inside the business union. */ +type KnownSession = + | MessageFeedbackSuccess + | MessageFeedbackRejected + +/** Validated note or one explicit request failure. */ +type ResolvedNote = + | MessageFeedbackSuccess + | MessageFeedbackRejected + +/** + * Storage-domain sidecar service. It inspects persisted Session history and + * never creates or resumes an Agent or Session. + */ +export class MessageFeedbackService extends GatewayService { + static inject = ['storageDomain', 'sessionPersistence', 'sessions'] + + /** Loader validation for the required note-size policy. */ + static Config: s = s.object({ + maxNoteBytes: s.number().step(1).min(1).required(), + }) + + private readonly maxNoteBytes: number + private table?: KvTable + private readonly operationTails = new Map>() + private mutationAdmissionOpen = true + + /** + * @param ctx - Host context carrying persistence and the storage-domain form. + * @param config - Required note-size policy. + */ + constructor(ctx: Context, config: Config) { + super(ctx, 'messageFeedback') + this.maxNoteBytes = resolveMaxNoteBytes(config.maxNoteBytes) + } + + /** Open and own the one message-feedback sidecar domain. */ + protected async [Service.init](): Promise { + const domain = await this.ctx.storageDomain.open(messageFeedbackDomainSpec) + this.ctx.effect(() => async () => { + this.mutationAdmissionOpen = false + await Promise.all(this.operationTails.values()) + await domain.close() + }, 'message-feedback.domainClose') + this.table = domain.table('sessions') + } + + /** + * Read feedback belonging to the current persisted Session lifecycle. + * A stale row from a reused Session id is invisible. + * @param request - Session identity to inspect and list. + * @returns current immutable items or `session-not-found`. + */ + @Remote('list') + async list(request: MessageFeedbackListRequest): Promise { + const known = await this.inspectSession(request.sessionId) + if (!known.ok) return known + const row = this.requireTable().get(request.sessionId) + const items = row !== undefined && sameIdentity(row, known.value.meta) ? row.items : EMPTY_ITEMS + return success(snapshotList(items)) + } + + /** + * Create or replace feedback for one derived append-origin assistant + * message. Every request must match the addressed item's current version; + * a matching no-op returns the stored item without changing its revision. + * @param request - target, desired value, and observed item version. + * @returns the committed item or an explicit business failure. + */ + @Remote('put') + put(request: MessageFeedbackPutRequest): Promise { + const note = this.resolveNote(request.note) + if (!note.ok) return Promise.resolve(note) + return this.enqueue(request.sessionId, async () => { + const known = await this.inspectSession(request.sessionId) + if (!known.ok) return known + if (!this.hasFeedbackTarget(known.value, request.messageId)) { + return rejected({ + code: 'target-not-found', + sessionId: request.sessionId, + messageId: request.messageId, + }) + } + + const durable = await this.ensureTargetDurable(known.value) + if (!sameHeaderIdentity(durable.meta, known.value.meta) + || !this.hasFeedbackTarget(durable, request.messageId)) { + return rejected({ + code: 'target-not-found', + sessionId: request.sessionId, + messageId: request.messageId, + }) + } + + const table = this.requireTable() + const stored = table.get(request.sessionId) + const current = stored !== undefined && sameIdentity(stored, durable.meta) ? stored : undefined + const items = current?.items ?? EMPTY_ITEMS + const index = items.findIndex(item => item.messageId === request.messageId) + const existing = items[index] + if (request.ifVersion !== (existing?.version ?? null)) { + return rejected(this.versionConflict(existing ?? null)) + } + if (existing !== undefined + && existing.rating === request.rating + && existing.note === note.value) { + return success(snapshotItem(existing)) + } + + const now = Date.now() + const item = snapshotItem({ + messageId: request.messageId, + rating: request.rating, + ...(note.value === undefined ? {} : { note: note.value }), + version: nextVersion(), + createdAt: existing?.createdAt ?? now, + updatedAt: existing === undefined ? now : Math.max(now, existing.updatedAt), + }) + const nextItems = [...items] + if (index === -1) nextItems.push(item) + else nextItems[index] = item + await table.put( + request.sessionId, + rowSnapshot(identityOf(durable.meta), nextItems), + ) + return success(snapshotItem(item)) + }) + } + + /** + * Delete one feedback item. Absence is successful regardless of the + * supplied version; an existing item requires an exact version match. + * @param request - Session, message, and observed item version. + * @returns the stable absent postcondition, or an explicit failure. + */ + @Remote('delete') + delete(request: MessageFeedbackDeleteRequest): Promise { + return this.enqueue(request.sessionId, async () => { + const known = await this.inspectSession(request.sessionId) + if (!known.ok) return known + + const table = this.requireTable() + const stored = table.get(request.sessionId) + const current = stored !== undefined && sameIdentity(stored, known.value.meta) ? stored : undefined + const items = current?.items ?? EMPTY_ITEMS + const existing = items.find(item => item.messageId === request.messageId) + if (existing === undefined) { + return success(Object.freeze({ absent: true })) + } + if (request.ifVersion !== existing.version) { + return rejected(this.versionConflict(existing)) + } + + await table.put( + request.sessionId, + rowSnapshot(identityOf(known.value.meta), items.filter(item => item !== existing)), + ) + return success(Object.freeze({ absent: true })) + }) + } + + /** + * Resolve a live owner directly; otherwise use the storage catalog as the + * existence authority before inspecting the log. Inspection failures for a + * catalogued Session remain infrastructure failures rather than being + * guessed into the business `session-not-found` branch. + */ + private async inspectSession(sessionId: SessionId): Promise { + if (this.ctx.sessions.get(sessionId) === undefined) { + const snapshots = await this.ctx.sessionPersistence.listSnapshots() + if (!snapshots.some(snapshot => snapshot.header.id === sessionId) + && this.ctx.sessions.get(sessionId) === undefined) { + return rejected({ code: 'session-not-found', sessionId }) + } + } + return success(await this.ctx.sessionPersistence.inspect(sessionId)) + } + + /** Require the exact finalized append-origin assistant message projection. */ + private hasFeedbackTarget(inspection: SessionInspection, messageId: MessageFeedbackItem['messageId']): boolean { + return inspection.events.some((event) => { + if (event.type !== 'assistant/message' || !isAppendSurfaceEvent(event)) return false + const message = deriveEventMessage(event) + return message?.role === 'assistant' && message.id === messageId + }) + } + + /** + * Put the target log prefix behind a durability barrier before its sidecar. + * A live owner flushes through the SessionStore's canonical checkpoint; a + * cold owner is re-read from the physical durable prefix. + */ + private async ensureTargetDurable(inspection: SessionInspection): Promise { + const live = this.ctx.sessions.get(inspection.meta.id) + if (live !== undefined && sameHeaderIdentity(live.header, inspection.meta)) { + if (!(await this.ctx.sessions.flush(live))) { + throw new Error( + `message-feedback: no durability listener participated for live session '${inspection.meta.id}'`, + ) + } + return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0) + } + return await this.ctx.sessionPersistence.readFrom(inspection.meta.id, 0) + } + + /** Validate optional-note semantics and the configured complete UTF-8 byte bound. */ + private resolveNote(note: string | undefined): ResolvedNote { + if (note === undefined) return success(undefined) + if (note.trim().length === 0) return rejected({ code: 'note-blank' }) + const actualBytes = Buffer.byteLength(note, 'utf8') + if (actualBytes > this.maxNoteBytes) { + return rejected({ code: 'note-too-large', maxBytes: this.maxNoteBytes, actualBytes }) + } + return success(note) + } + + /** Return the authoritative item needed to reconcile one failed comparison. */ + private versionConflict(current: MessageFeedbackItem | null): MessageFeedbackVersionConflict { + return { + code: 'version-conflict', + current: current === null ? null : snapshotItem(current), + } + } + + /** Queue a complete read/compare/write mutation behind this Session's prior mutation. */ + private enqueue(sessionId: SessionId, operation: () => Promise): Promise { + if (!this.mutationAdmissionOpen) { + return Promise.reject(new Error('message-feedback: service is disposing')) + } + const previous = this.operationTails.get(sessionId) ?? Promise.resolve() + const result = previous.then(operation) + const tail = result.then(() => undefined, () => undefined) + this.operationTails.set(sessionId, tail) + return result.finally(() => { + if (this.operationTails.get(sessionId) === tail) this.operationTails.delete(sessionId) + }) + } + + /** Resolve the initialized durable table or fail a broken service lifecycle. */ + private requireTable(): KvTable { + if (this.table === undefined) { + throw new Error('message-feedback: durable domain is not initialized') + } + return this.table + } +} + +export default MessageFeedbackService diff --git a/packages/feedback/message-feedback/src/invariant.ts b/packages/feedback/message-feedback/src/invariant.ts new file mode 100644 index 0000000000..5433f318f1 --- /dev/null +++ b/packages/feedback/message-feedback/src/invariant.ts @@ -0,0 +1,27 @@ +/** Package-owned invariant companion. @module @deepseek-ai/dsh-message-feedback/invariant */ + +/* jscpd:ignore-start */ +import type { Context } from '@deepseek-ai/cordis' +import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-message-feedback' + +/** Cordis companion plugin name. */ +export const name = 'message-feedback-invariant' +/** Services required before the companion can reserve and check package ownership. */ +export const inject = ['invariants'] + +/** + * No runtime invariant: the private typed writer owns current row mutations, + * the domain schema validates rows on reopen, and no second authority exists. + */ +const install: InvariantInstaller = Object.assign(() => {}, { inject: ['messageFeedback'] }) + +/** + * 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/feedback/message-feedback/src/spec.ts b/packages/feedback/message-feedback/src/spec.ts new file mode 100644 index 0000000000..d08a536d06 --- /dev/null +++ b/packages/feedback/message-feedback/src/spec.ts @@ -0,0 +1,90 @@ +/** + * Durable storage-domain declaration for lifecycle-bound message feedback. + * @module @deepseek-ai/dsh-message-feedback/src/spec + */ + +import { z } from 'zod' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' +import { defineDomain, domainTable } from '@deepseek-ai/dsh-storage-domain' +import type { MessageFeedbackItem, MessageFeedbackRating, MessageFeedbackVersion } from './types.ts' + +const nonNegativeSafeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER) + +/** Runtime schema for the closed rating vocabulary. */ +export const messageFeedbackRatingSchema = z.union([ + z.literal('positive'), + z.literal('negative'), +]) satisfies z.ZodType + +/** Runtime schema for one opaque item version stored on disk. */ +export const messageFeedbackVersionSchema = z.uuid() + .transform(value => value as MessageFeedbackVersion) + +/** Runtime schema for one current feedback item. */ +// Zod infers transformed branded fields structurally, so it cannot name the +// public interface even though every branded output is created below. +export const messageFeedbackItemSchema = z.object({ + messageId: z.string().min(1).transform(value => value as MessageId), + rating: messageFeedbackRatingSchema, + note: z.string().refine(note => note.trim().length > 0, { + message: 'message feedback note must contain a non-whitespace character', + }).optional(), + version: messageFeedbackVersionSchema, + createdAt: nonNegativeSafeInteger, + updatedAt: nonNegativeSafeInteger, +}).refine(item => item.updatedAt >= item.createdAt, { + path: ['updatedAt'], + message: 'message feedback updatedAt must not precede createdAt', +}) as unknown as z.ZodType + +/** Persisted Session fields that fence a sidecar row to one log lifecycle. */ +export const messageFeedbackSessionIdentitySchema = z.object({ + createdAt: nonNegativeSafeInteger, + cwd: z.string().optional(), +}) + +/** Persisted lifecycle identity inferred from its durable schema. */ +export type MessageFeedbackSessionIdentity = z.infer + +/** + * One whole-Session sidecar. Duplicate message ids would make item lookup + * ambiguous; duplicate versions would break their independent identity. + */ +export const messageFeedbackRowSchema = z.object({ + session: messageFeedbackSessionIdentitySchema, + items: z.array(messageFeedbackItemSchema), +}).superRefine((row, ctx) => { + const messageIds = new Set() + const versions = new Set() + row.items.forEach((item, index) => { + if (messageIds.has(item.messageId)) { + ctx.addIssue({ + code: 'custom', + path: ['items', index, 'messageId'], + message: `duplicate message feedback id '${item.messageId}'`, + }) + } + messageIds.add(item.messageId) + if (versions.has(item.version)) { + ctx.addIssue({ + code: 'custom', + path: ['items', index, 'version'], + message: `duplicate message feedback version '${item.version}'`, + }) + } + versions.add(item.version) + }) +}) + +/** Durable sidecar row inferred from {@link messageFeedbackRowSchema}. */ +export type MessageFeedbackRow = z.infer + +/** One lifecycle-bound sidecar record per Session id. */ +export const messageFeedbackDomainSpec = defineDomain({ + name: 'message_feedback', + version: 0, + tables: { + sessions: domainTable(messageFeedbackRowSchema), + }, +}) diff --git a/packages/feedback/message-feedback/src/types.ts b/packages/feedback/message-feedback/src/types.ts new file mode 100644 index 0000000000..0be57b17b4 --- /dev/null +++ b/packages/feedback/message-feedback/src/types.ts @@ -0,0 +1,147 @@ +/** + * Public request, value, and failure vocabulary for per-message feedback. + * This module contains types only so generated Remote clients can consume it + * without importing Host runtime code. + * @module @deepseek-ai/dsh-message-feedback/types + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Opaque compare-and-set token for one exact feedback item revision. */ +export type MessageFeedbackVersion = Branded<'MessageFeedbackVersion'> + +/** The human's overall judgment of one assistant message. */ +export type MessageFeedbackRating = 'positive' | 'negative' + +/** One current feedback value and its opaque mutation token. */ +export interface MessageFeedbackItem { + /** Stable identity of the assistant message inside the owning Session. */ + readonly messageId: MessageId + /** Overall positive or negative judgment. */ + readonly rating: MessageFeedbackRating + /** Optional explanation, preserved verbatim after validation. */ + readonly note?: string + /** Equality-only token replaced by every material create or update. */ + readonly version: MessageFeedbackVersion + /** Host-assigned creation time in Unix epoch milliseconds. */ + readonly createdAt: number + /** Host-assigned time of the most recent material update. */ + readonly updatedAt: number +} + +/** Read all message feedback belonging to one persisted Session lifecycle. */ +export interface MessageFeedbackListRequest { + /** Persisted Session whose sidecar should be read. */ + readonly sessionId: SessionId +} + +/** Current feedback values for one Session, in first-creation order. */ +export interface MessageFeedbackListValue { + /** Fresh immutable item snapshots. */ + readonly items: readonly MessageFeedbackItem[] +} + +/** Create or replace feedback for one assistant message. */ +export interface MessageFeedbackPutRequest { + /** Persisted Session that owns the target message. */ + readonly sessionId: SessionId + /** Target assistant-message identity. */ + readonly messageId: MessageId + /** Desired overall judgment. */ + readonly rating: MessageFeedbackRating + /** Optional non-blank explanation. */ + readonly note?: string + /** Observed item version, or `null` to require that no item exists. */ + readonly ifVersion: MessageFeedbackVersion | null +} + +/** Delete feedback for one message after observing its current version. */ +export interface MessageFeedbackDeleteRequest { + /** Persisted Session that owns the sidecar. */ + readonly sessionId: SessionId + /** Message whose feedback should be absent after this operation. */ + readonly messageId: MessageId + /** Observed item version; ignored when the item is already absent. */ + readonly ifVersion: MessageFeedbackVersion +} + +/** Idempotent deletion acknowledgement. */ +export interface MessageFeedbackDeleteValue { + /** Stable postcondition shared by the first deletion and every retry. */ + readonly absent: true +} + +/** No persisted Session header exists for the requested id. */ +export interface MessageFeedbackSessionNotFound { + readonly code: 'session-not-found' + readonly sessionId: SessionId +} + +/** The id does not name a derived, append-origin assistant message. */ +export interface MessageFeedbackTargetNotFound { + readonly code: 'target-not-found' + readonly sessionId: SessionId + readonly messageId: MessageId +} + +/** A material mutation did not match the addressed item's current version. */ +export interface MessageFeedbackVersionConflict { + readonly code: 'version-conflict' + /** Authoritative current item, or `null` when it does not exist. */ + readonly current: MessageFeedbackItem | null +} + +/** A supplied note contains no non-whitespace character. */ +export interface MessageFeedbackNoteBlank { + readonly code: 'note-blank' +} + +/** A supplied note exceeds the configured UTF-8 byte limit. */ +export interface MessageFeedbackNoteTooLarge { + readonly code: 'note-too-large' + readonly maxBytes: number + readonly actualBytes: number +} + +/** Failures shared by the public message-feedback operations. */ +export type MessageFeedbackFailure = + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + +/** Successful public operation result. */ +export interface MessageFeedbackSuccess { + readonly ok: true + readonly value: T +} + +/** Rejected public operation result with a stable business failure. */ +export interface MessageFeedbackRejected { + readonly ok: false + readonly error: E +} + +/** Result returned by the message-feedback `list` operation. */ +export type MessageFeedbackListResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected + +/** Result returned by the message-feedback `put` operation. */ +export type MessageFeedbackPutResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected< + | MessageFeedbackSessionNotFound + | MessageFeedbackTargetNotFound + | MessageFeedbackVersionConflict + | MessageFeedbackNoteBlank + | MessageFeedbackNoteTooLarge + > + +/** Result returned by the message-feedback `delete` operation. */ +export type MessageFeedbackDeleteResult = + | MessageFeedbackSuccess + | MessageFeedbackRejected diff --git a/packages/feedback/message-feedback/tests/helpers.ts b/packages/feedback/message-feedback/tests/helpers.ts new file mode 100644 index 0000000000..1dfaa24396 --- /dev/null +++ b/packages/feedback/message-feedback/tests/helpers.ts @@ -0,0 +1,213 @@ +import { mkdtemp, rm } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import SessionStore, { + SESSION_FORMAT_VERSION, + Session, + SessionId, + type SessionEvent, + type SessionHeader, +} from '@deepseek-ai/dsh-session' +import SessionPersistence, { + SessionPersistenceRevision, + type SessionInspection, + type SessionLocation, + type SessionPersistenceSnapshot, +} from '@deepseek-ai/dsh-session-persistence' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' +import MessageFeedbackService from '../src/index.ts' + +export interface MessageFixture { + readonly session: Session + readonly userMessageId: MessageId + readonly assistantMessageIds: readonly [MessageId, MessageId] + readonly emptyAssistantMessageId: MessageId + readonly replacementAssistantMessageId: MessageId +} + +/** Append one deterministic transcript surface used by target-validation tests. */ +export function appendMessageFixture(session: Session): Omit { + session.append('turn/start', { turn: 1 }) + session.append('step/start', { turn: 1, step: 1 }) + const user = createUserMessage({ + content: [{ type: 'text', text: 'Question' }], + source: { kind: 'user' }, + }) + session.append('user/message', user, { surfaceOp: 'append' }) + + const first = createAssistantMessage({ + content: [{ type: 'text', text: 'First answer' }], + source: { provider: 'test', model: 'test' }, + }) + const firstEvent = session.append('assistant/message', { + turn: 1, + step: 1, + message: first, + }, { surfaceOp: 'append' }) + const second = createAssistantMessage({ + content: [{ type: 'text', text: 'Second answer' }], + source: { provider: 'test', model: 'test' }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: second, + }, { surfaceOp: 'append' }) + const empty = createAssistantMessage({ + content: [], + source: { provider: 'test', model: 'test' }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: empty, + }, { surfaceOp: 'append' }) + session.append('step/end', { turn: 1, step: 1 }) + session.append('turn/end', { turn: 1, reason: { kind: 'completed' } }) + + const replacement = createAssistantMessage({ + content: [{ type: 'text', text: 'Model-only replacement' }], + source: { provider: 'test', model: 'test' }, + }) + session.append('assistant/message', { + turn: 1, + step: 1, + message: replacement, + }, { + surfaceOp: { op: 'replace', start: firstEvent.seq, end: firstEvent.seq }, + sourceEventSeqs: [firstEvent.seq], + }) + + return { + userMessageId: user.id, + assistantMessageIds: [first.id, second.id], + emptyAssistantMessageId: empty.id, + replacementAssistantMessageId: replacement.id, + } +} + +/** Construct one cold persistence fixture without publishing a live Session. */ +export function messageFixture( + rawId: string, + options: { readonly createdAt?: number; readonly cwd?: string } = {}, +): MessageFixture { + const id = SessionId(rawId) + const header: SessionHeader = { + version: SESSION_FORMAT_VERSION, + id, + createdAt: options.createdAt ?? 1_700_000_000_000, + ...(options.cwd === undefined ? {} : { cwd: options.cwd }), + } + const session = Session.create(id, [], header) + return { session, ...appendMessageFixture(session) } +} + +/** Minimal controllable persistence provider for service-level tests. */ +class TestPersistence extends SessionPersistence { + static inject = ['sessions'] + + readonly durable = new Map() + readonly logical = new Map() + inspectFailure: Error | undefined + inspectCalls = 0 + readFromCalls = 0 + onReadFrom: (() => void | Promise) | undefined + onListSnapshots: (() => void | Promise) | undefined + + locate(_meta: SessionHeader): SessionLocation | undefined { return undefined } + create(_meta: SessionHeader): Promise { return Promise.resolve() } + append(_id: SessionId, _events: readonly SessionEvent[]): Promise { return Promise.resolve() } + + load(id: SessionId): Promise { + return this.readFrom(id, 0) + } + + inspect(id: SessionId): Promise { + this.inspectCalls += 1 + if (this.inspectFailure !== undefined) return Promise.reject(this.inspectFailure) + const explicit = this.logical.get(id) + if (explicit !== undefined) return Promise.resolve(explicit) + const live = this.ctx.sessions.get(id) + if (live !== undefined) return Promise.resolve({ meta: live.header, events: live.events }) + const stored = this.durable.get(id) + return stored === undefined + ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) + : Promise.resolve(stored) + } + + async readFrom( + id: SessionId, + fromSeq: number, + ): Promise<{ meta: SessionHeader; events: SessionEvent[] }> { + this.readFromCalls += 1 + await this.onReadFrom?.() + const stored = this.durable.get(id) + return stored === undefined + ? Promise.reject(new Error(`test persistence: session '${id}' not found`)) + : { meta: stored.meta, events: stored.events.filter(event => event.seq >= fromSeq) } + } + + list(): Promise { + return Promise.resolve([...this.durable.values()].map(value => value.meta)) + } + + async listSnapshots(): Promise { + await this.onListSnapshots?.() + return [...this.durable.values()].map((value, index) => ({ + header: value.meta, + revision: SessionPersistenceRevision(`test:${index}:${value.events.length}`), + })) + } + + persist(session: Session): void { + this.durable.set(session.id, { meta: session.header, events: session.events }) + } + + setDurable(inspection: SessionInspection): void { + this.durable.set(inspection.meta.id, inspection) + } +} + +export interface TestHarness { + readonly ctx: Context + readonly persistence: TestPersistence + readonly root: string + disposeFeedback(): Promise + dispose(): Promise +} + +/** Compose the service over the real storage hub/domain/JSON backend. */ +export async function setupHarness(maxNoteBytes = 64): Promise { + const root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-test-')) + const ctx = new Context() + let disposeFeedback: (() => Promise) | undefined + try { + await ctx.plugin(SessionStore) + await ctx.plugin(TestPersistence) + await ctx.plugin(Storage) + await ctx.plugin(StorageJson, { root }) + await ctx.plugin(StorageDomain, { backend: 'json' }) + const feedbackFiber = await ctx.plugin(MessageFeedbackService, { maxNoteBytes }) + disposeFeedback = feedbackFiber.dispose + } catch (error) { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + throw error + } + if (disposeFeedback === undefined) throw new Error('message feedback test plugin did not load') + return { + ctx, + persistence: ctx.sessionPersistence as unknown as TestPersistence, + root, + disposeFeedback, + async dispose() { + await ctx.fiber.dispose() + await rm(root, { recursive: true, force: true }) + }, + } +} diff --git a/packages/feedback/message-feedback/tests/invariant.spec.ts b/packages/feedback/message-feedback/tests/invariant.spec.ts new file mode 100644 index 0000000000..e88131e147 --- /dev/null +++ b/packages/feedback/message-feedback/tests/invariant.spec.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest' +import InvariantService from '@deepseek-ai/dsh-invariants' +import * as MessageFeedbackInvariant from '../src/invariant.ts' +import { setupHarness } from './helpers.ts' + +describe('message-feedback invariant companion', () => { + it('removes its registry contribution when its fiber is disposed (HMR safety)', async () => { + const harness = await setupHarness() + try { + await harness.ctx.plugin(InvariantService) + const fiber = await harness.ctx.plugin(MessageFeedbackInvariant) + + expect(() => { + harness.ctx.invariants.register('@deepseek-ai/dsh-message-feedback', () => {}) + }).toThrow(/already registered/u) + + await fiber.dispose() + await expect(harness.ctx.plugin(MessageFeedbackInvariant).await()).resolves.toBeDefined() + } finally { + await harness.dispose() + } + }) +}) diff --git a/packages/feedback/message-feedback/tests/loader-composition.spec.ts b/packages/feedback/message-feedback/tests/loader-composition.spec.ts new file mode 100644 index 0000000000..98a7f29243 --- /dev/null +++ b/packages/feedback/message-feedback/tests/loader-composition.spec.ts @@ -0,0 +1,115 @@ +import { mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { pathToFileURL } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import Include from '@deepseek-ai/cordis-plugin-include' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' +import Storage from '@deepseek-ai/dsh-storage' +import * as StorageDomain from '@deepseek-ai/dsh-storage-domain' +import * as StorageJson from '@deepseek-ai/dsh-storage-json' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import MessageFeedbackService from '../src/index.ts' +import { appendMessageFixture } from './helpers.ts' + +let root: string | undefined +const contexts: Context[] = [] + +afterEach(async () => { + await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) + if (root !== undefined) await rm(root, { recursive: true, force: true }) + root = undefined +}) +async function loadComposition(configPath: string): Promise { + const ctx = new Context() + contexts.push(ctx) + ctx.baseUrl = pathToFileURL(root as string).href + '/' + await ctx.plugin(Loader) + ctx.loader.builtins.include = Include + const modules = new Map([ + ['@deepseek-ai/dsh-session', SessionStore], + ['@deepseek-ai/dsh-session-persistence-jsonl', SessionPersistenceJsonl], + ['@deepseek-ai/dsh-storage', Storage], + ['@deepseek-ai/dsh-storage-json', StorageJson], + ['@deepseek-ai/dsh-storage-domain', StorageDomain], + ['@deepseek-ai/dsh-message-feedback', MessageFeedbackService], + ]) + ctx.loader.internal = { + version: 'v2', + async import(specifier: string) { + if (!modules.has(specifier)) throw new Error(`unexpected Loader import: ${specifier}`) + return modules.get(specifier) + }, + } as unknown as NonNullable + await ctx.loader.create({ + name: 'cordis:include', + config: { path: pathToFileURL(configPath).href }, + }) + await ctx.loader.await() + const unloaded = [...ctx.loader.entries()] + .filter(entry => entry.fiber === undefined && !entry.disabled) + .map(entry => entry.options.name) + expect(unloaded).toEqual([]) + return ctx +} + +describe('message feedback through a real Loader composition', () => { + it('persists a checkpointed target and its sidecar across a cold restart', async () => { + root = await mkdtemp(join(tmpdir(), 'dsh-message-feedback-loader-')) + const configPath = join(root, 'cordis.yml') + await writeFile(configPath, [ + "- name: '@deepseek-ai/dsh-session'", + "- name: '@deepseek-ai/dsh-session-persistence-jsonl'", + ' config:', + ` root: ${JSON.stringify(join(root, 'sessions'))}`, + ' compression: none', + ' writeBatchMaxDelayMs: 1', + "- name: '@deepseek-ai/dsh-storage'", + "- name: '@deepseek-ai/dsh-storage-json'", + ' config:', + ` root: ${JSON.stringify(join(root, 'storage'))}`, + "- name: '@deepseek-ai/dsh-storage-domain'", + ' config:', + ' backend: json', + "- name: '@deepseek-ai/dsh-message-feedback'", + ' config:', + ' maxNoteBytes: 32', + '', + ].join('\n')) + + const first = await loadComposition(configPath) + expect(first.messageFeedback.typertGateway.namespace).toBe('messageFeedback') + expect(remoteMethods(first.messageFeedback).map(marker => marker.method)) + .toEqual(['list', 'put', 'delete']) + + const session = first.sessions.create(SessionId('loader-feedback'), { + meta: { cwd: root }, + }) + const fixture = appendMessageFixture(session) + const put = await first.messageFeedback.put({ + sessionId: session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + note: 'survives restart', + ifVersion: null, + }) + if (!put.ok) throw new Error(`expected put success, got ${put.error.code}`) + const durable = await first.sessionPersistence.readFrom(session.id, 0) + expect(durable.events.some(event => + event.type === 'assistant/message' + && event.data.message.id === fixture.assistantMessageIds[0])).toBe(true) + + await first.fiber.dispose() + contexts.splice(contexts.indexOf(first), 1) + + const second = await loadComposition(configPath) + await expect(second.messageFeedback.list({ sessionId: session.id })).resolves.toEqual({ + ok: true, + value: { items: [put.value] }, + }) + expect(second.sessions.get(session.id)).toBeUndefined() + }) +}) diff --git a/packages/feedback/message-feedback/tests/message-feedback.spec.ts b/packages/feedback/message-feedback/tests/message-feedback.spec.ts new file mode 100644 index 0000000000..f4478ec202 --- /dev/null +++ b/packages/feedback/message-feedback/tests/message-feedback.spec.ts @@ -0,0 +1,655 @@ +import { randomUUID } from 'node:crypto' +import { afterEach, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import type { MessageId } from '@deepseek-ai/dsh-llm/brand' +import { Session, SessionId } from '@deepseek-ai/dsh-session' +import { remoteMethods } from '@deepseek-ai/dsh-type-meta' +import MessageFeedbackService, { messageFeedbackRowSchema } from '../src/index.ts' +import type { + MessageFeedbackItem, + MessageFeedbackVersion, +} from '../src/index.ts' +import { + appendMessageFixture, + messageFixture, + setupHarness, + type TestHarness, +} from './helpers.ts' + +const harnesses: TestHarness[] = [] + +async function harness(maxNoteBytes = 64): Promise { + const value = await setupHarness(maxNoteBytes) + harnesses.push(value) + return value +} + +afterEach(async () => { + vi.useRealTimers() + await Promise.all(harnesses.splice(0).map(value => value.dispose())) +}) + +function staleVersion(): MessageFeedbackVersion { + return randomUUID() as MessageFeedbackVersion +} + +function expectItem( + result: Awaited>, +): MessageFeedbackItem { + if (!result.ok) throw new Error(`expected feedback item, got ${result.error.code}`) + return result.value +} + +describe('MessageFeedbackService public contract', () => { + it('publishes the exact Gateway namespace and Remote method names', async () => { + const { ctx } = await harness() + const binding = ctx.messageFeedback.typertGateway + expect(binding.serviceKey).toBe('messageFeedback') + expect(binding.namespace).toBe('messageFeedback') + expect(remoteMethods(ctx.messageFeedback)).toEqual([ + { method: 'list', invocation: { kind: 'direct' } }, + { method: 'put', invocation: { kind: 'direct' } }, + { method: 'delete', invocation: { kind: 'direct' } }, + ]) + }) + + it('returns session-not-found only for a definite persistence miss', async () => { + const { ctx, persistence } = await harness() + const missing = SessionId('missing-session') + await expect(ctx.messageFeedback.list({ sessionId: missing })).resolves.toEqual({ + ok: false, + error: { code: 'session-not-found', sessionId: missing }, + }) + + const fixture = messageFixture('corrupt-session') + persistence.setDurable({ meta: fixture.session.header, events: fixture.session.events }) + const corruption = new Error('stored log checksum mismatch') + persistence.inspectFailure = corruption + await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).rejects.toBe(corruption) + }) + + it('rechecks live ownership before returning a cold catalog miss', async () => { + const { ctx, persistence } = await harness() + const sessionId = SessionId('catalog-live-race') + const listed = Promise.withResolvers() + const release = Promise.withResolvers() + persistence.onListSnapshots = async () => { + listed.resolve(undefined) + await release.promise + } + + const pending = ctx.messageFeedback.list({ sessionId }) + await listed.promise + ctx.sessions.create(sessionId, { meta: { createdAt: 1_700_000_000_001 } }) + release.resolve(undefined) + + await expect(pending).resolves.toEqual({ ok: true, value: { items: [] } }) + expect(persistence.inspectCalls).toBe(1) + }) + + it('returns session-not-found from mutations and conflicts on an observed version for an absent item', async () => { + const { ctx, persistence } = await harness() + const missing = SessionId('missing-mutations') + const missingMessage = 'missing-message' as MessageId + await expect(ctx.messageFeedback.put({ + sessionId: missing, + messageId: missingMessage, + rating: 'positive', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { code: 'session-not-found', sessionId: missing }, + }) + await expect(ctx.messageFeedback.delete({ + sessionId: missing, + messageId: missingMessage, + ifVersion: staleVersion(), + })).resolves.toEqual({ + ok: false, + error: { code: 'session-not-found', sessionId: missing }, + }) + + const fixture = messageFixture('absent-version-conflict') + persistence.persist(fixture.session) + const expected = staleVersion() + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: expected, + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current: null }, + }) + }) + + it('creates, updates, and retry-reads immutable items with monotonic Host times', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('timestamps') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + + vi.useFakeTimers() + vi.setSystemTime(1_700_000_001_000) + const created = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: ' exact prose ', + ifVersion: null, + })) + expect(created).toMatchObject({ + messageId, + rating: 'positive', + note: ' exact prose ', + createdAt: 1_700_000_001_000, + updatedAt: 1_700_000_001_000, + }) + expect(created.version).toMatch(/^[0-9a-f-]{36}$/u) + expect(Object.isFrozen(created)).toBe(true) + + vi.setSystemTime(1_700_000_000_000) + const updated = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: created.version, + })) + expect(updated).toMatchObject({ + messageId, + rating: 'negative', + createdAt: created.createdAt, + updatedAt: created.updatedAt, + }) + expect(updated.version).not.toBe(created.version) + + const retry = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: updated.version, + })) + expect(retry).toEqual(updated) + + const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id }) + if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`) + expect(listed.value.items).toEqual([updated]) + expect(listed.value.items[0]).not.toBe(updated) + expect(Object.isFrozen(listed.value)).toBe(true) + expect(Object.isFrozen(listed.value.items)).toBe(true) + expect(Object.isFrozen(listed.value.items[0])).toBe(true) + }) + + it('reports non-blank and complete UTF-8 byte limits without touching persistence', async () => { + const { ctx, persistence } = await harness(4) + const fixture = messageFixture('note-limits') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + const before = persistence.inspectCalls + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: ' \n\t ', + ifVersion: null, + })).resolves.toEqual({ ok: false, error: { code: 'note-blank' } }) + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: 'ééé', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { code: 'note-too-large', maxBytes: 4, actualBytes: 6 }, + }) + expect(persistence.inspectCalls).toBe(before) + + expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + note: '😀', + ifVersion: null, + })) + }) + + it('accepts only non-empty append-origin assistant projections as targets', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('targets') + persistence.persist(fixture.session) + const rejectedTargets: MessageId[] = [ + fixture.userMessageId, + fixture.emptyAssistantMessageId, + fixture.replacementAssistantMessageId, + ] + for (const messageId of rejectedTargets) { + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { + code: 'target-not-found', + sessionId: fixture.session.id, + messageId, + }, + }) + } + expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })) + }) + + it('fails invalid direct configuration and a read before domain initialization', async () => { + const invalidCtx = new Context() + expect(() => new MessageFeedbackService(invalidCtx, { maxNoteBytes: 0 })) + .toThrow(/positive safe integer/u) + await invalidCtx.fiber.dispose() + + const fixture = messageFixture('uninitialized-domain') + const rawCtx = new Context() + rawCtx.provide('sessions', { get: () => undefined } as never) + rawCtx.provide('sessionPersistence', { + listSnapshots: () => Promise.resolve([{ header: fixture.session.header, revision: 'test' }]), + inspect: () => Promise.resolve({ meta: fixture.session.header, events: fixture.session.events }), + } as never) + const raw = new MessageFeedbackService(rawCtx, { maxNoteBytes: 1 }) + await expect(raw.list({ sessionId: fixture.session.id })) + .rejects.toThrow(/durable domain is not initialized/u) + await rawCtx.fiber.dispose() + }) + + it('rejects durable rows with duplicate message ids or reused item versions', () => { + const version = staleVersion() + const duplicate = messageFeedbackRowSchema.safeParse({ + session: { createdAt: 1 }, + items: [ + { + messageId: 'same-message', + rating: 'positive', + version, + createdAt: 1, + updatedAt: 1, + }, + { + messageId: 'same-message', + rating: 'negative', + version, + createdAt: 1, + updatedAt: 1, + }, + ], + }) + expect(duplicate.success).toBe(false) + if (duplicate.success) throw new Error('expected duplicate row rejection') + expect(duplicate.error.issues.map(issue => issue.path.join('.'))) + .toEqual(['items.1.messageId', 'items.1.version']) + }) +}) + +describe('MessageFeedbackService item concurrency', () => { + it('serializes whole-row writes while keeping versions independent per message', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('concurrent-items') + persistence.persist(fixture.session) + const [firstId, secondId] = fixture.assistantMessageIds + + const [firstResult, secondResult] = await Promise.all([ + ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: firstId, + rating: 'positive', + ifVersion: null, + }), + ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: secondId, + rating: 'negative', + ifVersion: null, + }), + ]) + const first = expectItem(firstResult) + const second = expectItem(secondResult) + const updated = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: firstId, + rating: 'negative', + note: 'changed', + ifVersion: first.version, + })) + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: firstId, + rating: 'positive', + note: 'stale change', + ifVersion: first.version, + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current: updated }, + }) + + const listed = await ctx.messageFeedback.list({ sessionId: fixture.session.id }) + if (!listed.ok) throw new Error(`expected list success, got ${listed.error.code}`) + expect(listed.value.items).toEqual([updated, second]) + expect(listed.value.items[1]?.version).toBe(second.version) + }) + + it('rejects a stale put even when the current value has returned to the same state', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('put-aba') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + const first = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: null, + })) + const second = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: first.version, + })) + const current = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: second.version, + })) + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: first.version, + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current }, + }) + }) + + it('makes delete retries stable and prevents delete/recreate ABA', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('delete-aba') + persistence.persist(fixture.session) + const messageId = fixture.assistantMessageIds[0] + const created = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'positive', + ifVersion: null, + })) + + await expect(ctx.messageFeedback.delete({ + sessionId: fixture.session.id, + messageId, + ifVersion: staleVersion(), + })).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current: created }, + }) + const request = { + sessionId: fixture.session.id, + messageId, + ifVersion: created.version, + } + await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({ + ok: true, + value: { absent: true }, + }) + await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({ + ok: true, + value: { absent: true }, + }) + + const recreated = expectItem(await ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId, + rating: 'negative', + ifVersion: null, + })) + expect(recreated.version).not.toBe(created.version) + await expect(ctx.messageFeedback.delete(request)).resolves.toEqual({ + ok: false, + error: { code: 'version-conflict', current: recreated }, + }) + }) + + it('fences a reused Session id and lets the new lifecycle start cleanly', async () => { + const { ctx, persistence } = await harness() + const old = messageFixture('reused-session', { createdAt: 10, cwd: '/old' }) + persistence.persist(old.session) + const oldItem = expectItem(await ctx.messageFeedback.put({ + sessionId: old.session.id, + messageId: old.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })) + + const replacement = Session.create( + old.session.id, + old.session.events, + { ...old.session.header, createdAt: 20, cwd: '/new' }, + ) + persistence.persist(replacement) + await expect(ctx.messageFeedback.list({ sessionId: replacement.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + await expect(ctx.messageFeedback.delete({ + sessionId: replacement.id, + messageId: old.assistantMessageIds[0], + ifVersion: oldItem.version, + })).resolves.toEqual({ ok: true, value: { absent: true } }) + + const newItem = expectItem(await ctx.messageFeedback.put({ + sessionId: replacement.id, + messageId: old.assistantMessageIds[0], + rating: 'negative', + ifVersion: null, + })) + expect(newItem.version).not.toBe(oldItem.version) + }) + + it('drains admitted mutations before domain close and rejects later admission', async () => { + const current = await harness() + const { ctx, persistence } = current + const fixture = messageFixture('dispose-quiescence') + persistence.persist(fixture.session) + const service = ctx.messageFeedback + const lifecycle = service as unknown as { readonly mutationAdmissionOpen: boolean } + const started = Promise.withResolvers() + const release = Promise.withResolvers() + let physicalReads = 0 + let committed = 0 + persistence.onReadFrom = async () => { + physicalReads += 1 + if (physicalReads !== 1) return + started.resolve(undefined) + await release.promise + } + ctx.on('domain/changed', (change) => { + if (change.domain === 'message_feedback') committed += 1 + }) + + const first = service.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + }) + await started.promise + const second = service.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[1], + rating: 'negative', + ifVersion: null, + }) + const disposal = current.disposeFeedback() + await vi.waitFor(() => { expect(lifecycle.mutationAdmissionOpen).toBe(false) }) + + await expect(service.delete({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + ifVersion: staleVersion(), + })).rejects.toThrow('message-feedback: service is disposing') + release.resolve(undefined) + + expectItem(await first) + expectItem(await second) + await disposal + expect(physicalReads).toBe(2) + expect(committed).toBe(2) + }) +}) + +describe('MessageFeedbackService durability ordering', () => { + it('rejects a logical target missing from the cold physical durable prefix', async () => { + const { ctx, persistence } = await harness() + const fixture = messageFixture('cold-prefix') + persistence.logical.set(fixture.session.id, { + meta: fixture.session.header, + events: fixture.session.events, + }) + persistence.setDurable({ meta: fixture.session.header, events: [] }) + + await expect(ctx.messageFeedback.put({ + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).resolves.toEqual({ + ok: false, + error: { + code: 'target-not-found', + sessionId: fixture.session.id, + messageId: fixture.assistantMessageIds[0], + }, + }) + expect(persistence.readFromCalls).toBe(1) + await expect(ctx.messageFeedback.list({ sessionId: fixture.session.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + }) + + it('commits and physically verifies a live target checkpoint before the sidecar write', async () => { + const { ctx, persistence } = await harness() + const session = ctx.sessions.create(SessionId('live-checkpoint'), { + meta: { createdAt: 30, cwd: '/live' }, + }) + const fixture = appendMessageFixture(session) + const order: string[] = [] + ctx.on('session/flush', (current) => { + order.push('session:durable') + persistence.persist(current) + }) + ctx.on('domain/changed', (change) => { + if (change.domain === 'message_feedback') order.push('sidecar:durable') + }) + persistence.onReadFrom = () => { order.push('session:verified') } + + expectItem(await ctx.messageFeedback.put({ + sessionId: session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })) + expect(order).toEqual(['session:durable', 'session:verified', 'sidecar:durable']) + expect(persistence.readFromCalls).toBe(1) + expect(persistence.durable.get(session.id)?.events).toContainEqual( + expect.objectContaining({ type: 'assistant/message' }), + ) + }) + + it('fails closed when a live checkpoint fails, has no participant, or is not physically durable', async () => { + const failed = await harness() + const failedSession = failed.ctx.sessions.create(SessionId('live-flush-failure')) + const failedFixture = appendMessageFixture(failedSession) + const diskFailure = new Error('disk unavailable') + failed.ctx.on('session/flush', () => { throw diskFailure }) + await expect(failed.ctx.messageFeedback.put({ + sessionId: failedSession.id, + messageId: failedFixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).rejects.toBe(diskFailure) + await expect(failed.ctx.messageFeedback.list({ sessionId: failedSession.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + + const absent = await harness() + const absentSession = absent.ctx.sessions.create(SessionId('live-no-flush')) + const absentFixture = appendMessageFixture(absentSession) + await expect(absent.ctx.messageFeedback.put({ + sessionId: absentSession.id, + messageId: absentFixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).rejects.toThrow(/no durability listener participated/u) + await expect(absent.ctx.messageFeedback.list({ sessionId: absentSession.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + + const noDurability = await harness() + const unpersistedSession = noDurability.ctx.sessions.create(SessionId('live-unpersisted')) + const unpersistedFixture = appendMessageFixture(unpersistedSession) + noDurability.ctx.on('session/flush', () => {}) + await expect(noDurability.ctx.messageFeedback.put({ + sessionId: unpersistedSession.id, + messageId: unpersistedFixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + })).rejects.toThrow(/not found/u) + expect(noDurability.persistence.durable.has(unpersistedSession.id)).toBe(false) + await expect(noDurability.ctx.messageFeedback.list({ sessionId: unpersistedSession.id })).resolves.toEqual({ + ok: true, + value: { items: [] }, + }) + }) + + it('finishes the captured live checkpoint when the Session detaches mid-flush', async () => { + const { ctx, persistence } = await harness() + const session = ctx.sessions.prepare(SessionId('detach-during-flush'), { + meta: { createdAt: 40, cwd: '/detach' }, + }) + const detach = ctx.sessions.enter(session) + ctx.sessions.announce(session) + const fixture = appendMessageFixture(session) + const started = Promise.withResolvers() + const release = Promise.withResolvers() + ctx.on('session/flush', async (current) => { + started.resolve(undefined) + await release.promise + persistence.persist(current) + }) + + const pending = ctx.messageFeedback.put({ + sessionId: session.id, + messageId: fixture.assistantMessageIds[0], + rating: 'positive', + ifVersion: null, + }) + await started.promise + detach() + expect(ctx.sessions.get(session.id)).toBeUndefined() + release.resolve(undefined) + expectItem(await pending) + expect(persistence.readFromCalls).toBe(1) + await expect(ctx.messageFeedback.list({ sessionId: session.id })).resolves.toMatchObject({ + ok: true, + value: { items: [{ messageId: fixture.assistantMessageIds[0] }] }, + }) + }) +}) diff --git a/packages/feedback/message-feedback/tsconfig.json b/packages/feedback/message-feedback/tsconfig.json new file mode 100644 index 0000000000..e406c4c00e --- /dev/null +++ b/packages/feedback/message-feedback/tsconfig.json @@ -0,0 +1,45 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "rootDir": "src", + "outDir": "lib/types" + }, + "include": [ + "src" + ], + "references": [ + { + "path": "../../../vendor/cosmokit" + }, + { + "path": "../../../vendor/cordis" + }, + { + "path": "../../../vendor/schemastery" + }, + { + "path": "../../util/brand" + }, + { + "path": "../../llm/llm" + }, + { + "path": "../../core/session" + }, + { + "path": "../../session/session-persistence" + }, + { + "path": "../../storage/storage" + }, + { + "path": "../../storage/storage-domain" + }, + { + "path": "../../typert/type-meta" + }, + { + "path": "../../support/invariants" + } + ] +} diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index 149ee2be48..74f6042cf0 100644 --- a/packages/fs/fs-local/README.i18n.yaml +++ b/packages/fs/fs-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/fs/fs-local/README.md -README.md: a3239905e3eebaae7fa3099122ee3a4ed91d3fe8 -README.zh.md: bbd9d2f66c4e582011bd0ea459e6c342eb653bda +README.md: d17dc0747833a0ecb85505260badc79ad739f27f +README.zh.md: e13ab2f04b84b59fbb3846c08139e679b2d43be1 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index a3239905e3..d17dc07478 100644 --- a/packages/fs/fs-local/README.md +++ b/packages/fs/fs-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **local-filesystem implementation** of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)). Backs the eleven `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`. +The **local-filesystem implementation** of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)). Backs the twelve `FileSystem` primitives with the host filesystem; loading it as a plugin populates `ctx.fs`. ```ts ignore-check import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' @@ -18,6 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **Execution-world coordinates** — `processPath` exposes the target's canonical host path, `fileUrl` encodes that path through Node's platform-aware URL conversion, and `contains` uses platform path semantics to test identity or descendant containment without consumers parsing `targetKey`. - **`stat` / `lstat`** — return target metadata or `undefined` when absent. `stat` reports `FsInfo` for an already resolved target (`version` = an opaque token derived from bigint `dev:ino:size:mtimeNs:ctimeNs`, `type` of `file`/`directory`/`other`, byte `size`); path-shaped `lstat` reports `FsPathInfo` without following the final symlink and can therefore return `symlink`. Both check cancellation before and after their asynchronous metadata probe, so an abort that lands in flight reports `FS_ABORTED` rather than stale absence. - **`readText` / `streamText`** — UTF-8 only. `readText` reads the whole file; `streamText` decodes chunks so a huge file need not be held whole in memory and consumers can enforce their own retention bounds. Both reject invalid UTF-8 and NUL-byte binary samples (`FS_NOT_TEXT`) and non-regular targets. The `read` tool (`@deepseek-ai/dsh-tool-fs`) owns line windowing. +- **`readBytes`** — raw whole-file bytes with no decoding or binary rejection (the `read_image` tool validates content through the attachment service). The required byte cap short-circuits on the stat size before any content I/O; the subsequent stream reads at most one byte beyond the cap, so a file growing after stat still fails `FS_TOO_LARGE` without unbounded buffering. - **`listDir`** — lists one directory level in stable `name.localeCompare()` order. Each entry carries the child basename, type, resolved child target (`displayPath` under the listed directory, `targetKey` as the realpath identity), and cheap stat metadata (`version`, plus `size` for regular files). It never opens or decodes file contents. Missing targets report `FS_NOT_FOUND`, file/special-file targets report `FS_NOT_DIRECTORY`, aborted calls report `FS_ABORTED`, permission failures report `FS_PERMISSION_DENIED`, and other listing or child metadata I/O failures report `FS_IO_ERROR`. Broken/disappeared children are returned as `other` without metadata, but permission/IO failures while resolving a child fail the whole listing with a structured `FsError`. - **`writeText`** — atomic: writes to a temp file opened exclusively (`wx`, `0o600`) inside a randomly-named private staging dir (`0o700`) next to the target, then fsyncs and publishes. An existing file's mode is preserved, while new files default to `0o600`; on Windows a new file inherits the destination directory's DACL, while replacement copies the target DACL onto the empty temp before writing and publishes through `ReplaceFileW` so the original access policy survives ([Windows DACL preservation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md)). The `expected` guard is OPTIONAL: omitting it unconditionally creates-or-overwrites; `createIfAbsent` hard-links the staged file into place as an atomic no-replace publication, so a regular file created after the initial probe is preserved and rejected with `FS_NOT_OBSERVED`, while a non-regular path entry is preserved and rejected with `FS_NOT_REGULAR_FILE`; `replaceIfVersion` replaces only at the observed version (a missing target or mismatch is `FS_STALE_VERSION`). An overwrite returns the prior text as its contextual diff basis only when both the opened prior file and UTF-8 replacement are strictly below `config.diffBasisMaxBytes` (default 10 MiB). The descriptor read enforces that limit even if an external writer replaces or changes the file size after the initial probe. Otherwise the provider returns `before: null`, so presentation uses its whole-file fallback. - **`editText`** — atomic literal read-modify-write over the same primitive, serialized per target by a mutation lock. The `expected` guard is OPTIONAL: when supplied it verifies the version BEFORE literal matching (a stale edit reports `FS_STALE_VERSION`, never `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT` against newer content); omitting it edits the current content unconditionally. A missing target reports `FS_STALE_VERSION` either way. LF-normalizes for matching, restores the file's dominant CRLF/LF style, and rejects empty `oldString` / zero matches (`FS_EDIT_NOT_FOUND`) or ambiguous multi-matches without `replace_all` (`FS_AMBIGUOUS_EDIT`). diff --git a/packages/fs/fs-local/README.zh.md b/packages/fs/fs-local/README.zh.md index bbd9d2f66c..e13ab2f04b 100644 --- a/packages/fs/fs-local/README.zh.md +++ b/packages/fs/fs-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -`ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十一个 `FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`。 +`ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))的**本地文件系统实现**。它使用宿主文件系统支持十二个 `FileSystem` 原语;将其作为插件加载会填充 `ctx.fs`。 ```ts ignore-check import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' @@ -18,6 +18,7 @@ await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) - **执行世界坐标**:`processPath` 公开目标的规范化宿主路径,`fileUrl` 通过 Node 的平台感知 URL 转换对该路径编码,`contains` 则使用平台路径语义检查身份相等或后代包含关系,消费方无需解析 `targetKey`。 - **`stat` / `lstat`**:返回目标元数据;目标不存在时返回 `undefined`。`stat` 为已解析目标报告 `FsInfo`(`version` 是由 bigint `dev:ino:size:mtimeNs:ctimeNs` 派生的不透明 token,`type` 为 `file`/`directory`/`other`,`size` 以字节计);路径形态的 `lstat` 不跟随最后一个符号链接,报告 `FsPathInfo`,因此可以返回 `symlink`。两者都会在异步元数据探测前后检查取消,因此飞行中的中止会报告 `FS_ABORTED`,而非陈旧的不存在结果。 - **`readText` / `streamText`**:只支持 UTF-8。`readText` 读取整个文件;`streamText` 按分片解码,因此超大文件无需整体保存在内存中,消费方也可以执行各自的保留上限。两者都会拒绝无效 UTF-8、包含 NUL 字节的二进制样本(`FS_NOT_TEXT`)以及非普通文件目标。`read` 工具(`@deepseek-ai/dsh-tool-fs`)拥有行窗口逻辑。 +- **`readBytes`**:按原始字节读取整个文件,不做解码或二进制拒绝(`read_image` 工具通过附件服务校验内容)。必填的字节上限在任何内容 I/O 之前先按 stat 大小短路;随后的流最多多读一个字节,因此 stat 之后增长的文件仍会以 `FS_TOO_LARGE` 失败,不会无界缓冲。 - **`listDir`**:按稳定的 `name.localeCompare()` 顺序列出一层目录。每个条目携带子项 basename、类型、解析后的子目标(`displayPath` 位于所列目录下,`targetKey` 是 realpath 身份)和低成本 stat 元数据(`version`,普通文件另有 `size`)。它绝不会打开或解码文件内容。缺失目标报告 `FS_NOT_FOUND`,文件/特殊文件目标报告 `FS_NOT_DIRECTORY`,已中止调用报告 `FS_ABORTED`,权限失败报告 `FS_PERMISSION_DENIED`,其他列出或子项元数据 I/O 失败报告 `FS_IO_ERROR`。损坏/消失的子项以无元数据的 `other` 返回,但解析子项时出现权限/I/O 失败会让整个列表以结构化 `FsError` 失败。 - **`writeText`**:原子写入。它会向排他打开的临时文件(`wx`、`0o600`)写入;该文件位于目标旁随机命名的私有暂存目录(`0o700`)内,随后执行 fsync 并发布。现有文件的 mode 会保留,新文件默认为 `0o600`;Windows 上的新文件继承目标目录的 DACL,而替换会在写入前把目标 DACL 复制到空临时文件,并通过 `ReplaceFileW` 发布,使原访问政策得以保留(见 [Windows DACL 保留 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-07-19-windows-atomic-write-dacl-preservation.md))。`expected` 防护是可选的:省略时无条件创建或覆盖;`createIfAbsent` 通过硬链接把暂存文件发布到目标位置,以实现原子且不替换的发布,因此初始探测后创建的普通文件会被保留,并以 `FS_NOT_OBSERVED` 拒绝本次写入;非普通路径条目也会被保留,并以 `FS_NOT_REGULAR_FILE` 拒绝;`replaceIfVersion` 只在观察到的版本上替换(目标缺失或版本不匹配均为 `FS_STALE_VERSION`)。仅当打开后的旧文件和 UTF-8 替换内容都严格低于 `config.diffBasisMaxBytes`(默认 10 MiB)时,覆写才返回旧文本作为上下文 diff 基础。即使外部写入方在初次探测后替换文件或改变文件大小,文件描述符读取仍会强制执行该上限;否则提供方返回 `before: null`,由展示层使用整文件回退。 - **`editText`**:在同一原语之上依次执行原子的字面量读取、修改和写入,并通过变更锁按目标串行化。`expected` 防护是可选的:提供时,会在字面量匹配之前校验版本(陈旧编辑报告 `FS_STALE_VERSION`,绝不会针对较新内容报告 `FS_EDIT_NOT_FOUND`/`FS_AMBIGUOUS_EDIT`);省略时,无条件编辑当前内容。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。匹配时规范化为 LF,随后恢复文件主要的 CRLF/LF 风格;空 `oldString` / 零匹配报告 `FS_EDIT_NOT_FOUND`,未设置 `replace_all` 的多个匹配则报告 `FS_AMBIGUOUS_EDIT`。 diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 50135f0f50..77ed82d979 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/fs/fs-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,18 +32,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "koffi": "^3.1.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/fs-local/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 1ba027455d..17bfb6115b 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -98,6 +98,8 @@ export interface FsIoInternals { removeStagingDir?: (stagingDir: string) => Promise /** Test hook after the temp file is written/synced but before final chmod+publication. */ inspectTemp?: (paths: { stagingDir: string; tempPath: string }) => void | Promise + /** Test hook after raw-read stat preflight and before bounded content I/O. */ + inspectReadBytesAfterStat?: (target: LocalTarget) => void | Promise } /** A resolved local path: the absolute path shown to callers and its realpath identity. */ @@ -380,6 +382,50 @@ export async function readWholeText(target: LocalTarget, signal?: AbortSignal): return decodeUtf8(raw, 'read', target.displayPath) } +/** + * Read a whole regular file as raw bytes with no decoding or binary rejection. + * `maxBytes` bounds the complete content: the stat size short-circuits an + * oversized file before any content I/O, and the stream reads at most one byte + * beyond the cap so a file growing after stat cannot cause unbounded buffering. + * @param target - the resolved file to read. + * @param signal - aborts the read (`FS_ABORTED`). + * @param maxBytes - inclusive byte cap on the complete content (`FS_TOO_LARGE`). + * @param internals - test seam for a deterministic post-stat growth race. + * @returns the full raw content, at most `maxBytes` long. + */ +export async function readWholeBytes( + target: LocalTarget, + signal: AbortSignal | undefined, + maxBytes: number, + internals: FsIoInternals = {}, +): Promise { + const info = await statRegularFile(target, 'read', signal) + if (info.size > maxBytes) { + throw new FsError(`cannot read "${target.displayPath}": ${info.size} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') + } + await internals.inspectReadBytesAfterStat?.(target) + const stream = createReadStream(target.targetKey, { + end: maxBytes, + ...signal ? { signal } : {}, + }) + const chunks: Buffer[] = [] + let bytes = 0 + try { + for await (const chunk of stream as AsyncIterable) { + bytes += chunk.length + if (bytes > maxBytes) { + throw new FsError(`cannot read "${target.displayPath}": content exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') + } + chunks.push(chunk) + } + } catch (error: unknown) { + /* v8 ignore next 2 -- a mid-stream abort needs cancellation racing an active read; pre-abort is deterministic. */ + if (isAbortError(error)) throw new FsError('read aborted', 'FS_ABORTED') + throw error + } + return Buffer.concat(chunks, bytes) +} + /** * Stream a whole regular UTF-8 text file as decoded text chunks. Same text * semantics as {@link readWholeText} (regular-file check, binary/NUL rejection, diff --git a/packages/fs/fs-local/src/index.ts b/packages/fs/fs-local/src/index.ts index 5d1f6b2865..661ef236b8 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -4,11 +4,11 @@ * @module @deepseek-ai/dsh-fs-local */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { constants as bufferConstants } from 'node:buffer' import { isAbsolute, relative, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { FileSystem, FsError, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -28,6 +28,7 @@ import { probeNoFollow, readForEdit, readTextForDiff, + readWholeBytes, readWholeText, resolveLocalTarget, restoreLineEndings, @@ -147,6 +148,10 @@ export class LocalFileSystem extends FileSystem { return Promise.resolve(streamWholeText({ displayPath: target.displayPath, targetKey: target.targetKey }, signal)) } + override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise { + return readWholeBytes({ displayPath: target.displayPath, targetKey: target.targetKey }, signal, maxBytes, this.internals) + } + override async listDir(target: FsTarget, signal?: AbortSignal): Promise { const entries = await listDirectory({ displayPath: target.displayPath, targetKey: target.targetKey }, signal) return entries.map(entry => ({ diff --git a/packages/fs/fs-local/src/invariant.ts b/packages/fs/fs-local/src/invariant.ts index 3e38550065..07a8a62246 100644 --- a/packages/fs/fs-local/src/invariant.ts +++ b/packages/fs/fs-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-local' diff --git a/packages/fs/fs-local/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index d70581f28e..bad67e6eb8 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -12,7 +12,7 @@ import { mkdir, mkdtemp, readFile, realpath, rm, stat, symlink, unlink, utimes, import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import { FsVersion } from '@deepseek-ai/dsh-fs' import type { FsTarget } from '@deepseek-ai/dsh-fs' @@ -265,6 +265,43 @@ describe('readText / streamText', () => { }) }) +describe('readBytes', () => { + it('reads raw bytes without decoding or NUL rejection', async () => { + const raw = Buffer.from([0x68, 0x00, 0x69, 0xff]) + await writeFile(join(dir, 'a.bin'), raw) + expect(Buffer.from(await fs.readBytes(await fs.resolve('a.bin'), undefined, raw.length))).toEqual(raw) + }) + + it('accepts a file exactly at maxBytes and rejects one past it', async () => { + await writeFile(join(dir, 'a.bin'), Buffer.alloc(4, 1)) + const target = await fs.resolve('a.bin') + expect((await fs.readBytes(target, undefined, 4)).length).toBe(4) + await expect(fs.readBytes(target, undefined, 3)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' }) + }) + + it('bounds content I/O when a file grows after stat preflight', async () => { + await writeFile(join(dir, 'a.bin'), Buffer.alloc(4, 1)) + const target = await fs.resolve('a.bin') + fs.internals.inspectReadBytesAfterStat = () => writeFile(join(dir, 'a.bin'), Buffer.alloc(1024 * 1024, 2)) + + await expect(fs.readBytes(target, undefined, 4)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' }) + }) + + it('rejects a missing file and a directory', async () => { + await expect(fs.readBytes(await fs.resolve('nope'), undefined, 1024)).rejects.toMatchObject({ code: 'FS_NOT_FOUND' }) + await expect(fs.readBytes(await fs.resolve('.'), undefined, 1024)).rejects.toMatchObject({ code: 'FS_NOT_REGULAR_FILE' }) + }) + + it('reads under a live signal and rejects an already-aborted one with FS_ABORTED', async () => { + await writeFile(join(dir, 'a.bin'), 'data') + const live = new AbortController() + expect((await fs.readBytes(await fs.resolve('a.bin'), live.signal, 1024)).length).toBe(4) + const controller = new AbortController() + controller.abort() + await expect(fs.readBytes(await fs.resolve('a.bin'), controller.signal, 1024)).rejects.toMatchObject({ code: 'FS_ABORTED' }) + }) +}) + describe('listDir', () => { it('lists files and directories in stable name order with resolved child targets', async () => { await mkdir(join(dir, 'skills', 'dir-skill'), { recursive: true }) diff --git a/packages/fs/fs-policy/README.i18n.yaml b/packages/fs/fs-policy/README.i18n.yaml index 65239a6f00..d889e039de 100644 --- a/packages/fs/fs-policy/README.i18n.yaml +++ b/packages/fs/fs-policy/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/fs/fs-policy/README.md -README.md: 395a36e89e113dc3846a8dff62ce90601013addd -README.zh.md: 5b3b1f64de2d51f6b729392da2d4b459a9f0ca3b +README.md: 59865d063095d666244c2ec86b8604f006f70554 +README.zh.md: af84614194db2afef3865a62e1030a2849e48947 diff --git a/packages/fs/fs-policy/README.md b/packages/fs/fs-policy/README.md index 395a36e89e..59865d0630 100644 --- a/packages/fs/fs-policy/README.md +++ b/packages/fs/fs-policy/README.md @@ -5,7 +5,7 @@ English | [中文](README.zh.md) The **fs-policy plugin**: it records observed presence or absence and adds read-before-edit plus guarded write/edit on top of the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) — through the `fs/*` event gate, **NOT** through a method service. This plugin registers **no** `ctx.fsPolicy` service and has no public `read`/`write`/`edit`/`resolve` methods. It is the policy third of the filesystem stack: not a swappable seam, but the policy that does not belong on the `FileSystem` provider base class. ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' declare const ctx: Context diff --git a/packages/fs/fs-policy/README.zh.md b/packages/fs/fs-policy/README.zh.md index 5b3b1f64de..af84614194 100644 --- a/packages/fs/fs-policy/README.zh.md +++ b/packages/fs/fs-policy/README.zh.md @@ -5,7 +5,7 @@ **fs-policy 插件**:它记录观测到的存在或缺失状态,并在 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))之上增加编辑前读取和带防护的写入/编辑;它通过 `fs/*` 事件门禁参与,**不是**通过方法服务。该插件**不**注册 `ctx.fsPolicy` 服务,也没有公开的 `read`/`write`/`edit`/`resolve` 方法。它是文件系统栈的政策层:不是可替换 seam,而是不应位于 `FileSystem` 提供方基类上的政策。 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' declare const ctx: Context diff --git a/packages/fs/fs-policy/package.json b/packages/fs/fs-policy/package.json index 634fb114f5..a9e204ba90 100644 --- a/packages/fs/fs-policy/package.json +++ b/packages/fs/fs-policy/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-fs-policy", "description": "File-context policy plugin for the DeepSeek Harness — observed-state, read-before-edit, and version-guarded write/edit added over the ctx.fs provider seam through the fs/* event gate (no service surface)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/fs/fs-policy" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,14 +32,14 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/fs-policy/src/index.ts b/packages/fs/fs-policy/src/index.ts index c7feb8c484..91a9b4ec95 100644 --- a/packages/fs/fs-policy/src/index.ts +++ b/packages/fs/fs-policy/src/index.ts @@ -7,7 +7,7 @@ * @module @deepseek-ai/dsh-fs-policy */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { FsError } from '@deepseek-ai/dsh-fs' import type { FsObservation, FsTarget, FsVersion, FsWriteIntent } from '@deepseek-ai/dsh-fs' import type { FsPolicyExec } from './types.ts' diff --git a/packages/fs/fs-policy/src/invariant.ts b/packages/fs/fs-policy/src/invariant.ts index 369fa5ea84..cd0ff6d2a4 100644 --- a/packages/fs/fs-policy/src/invariant.ts +++ b/packages/fs/fs-policy/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-policy' diff --git a/packages/fs/fs-policy/tests/policy.spec.ts b/packages/fs/fs-policy/tests/policy.spec.ts index 2c977cc3be..27dad3b5fc 100644 --- a/packages/fs/fs-policy/tests/policy.spec.ts +++ b/packages/fs/fs-policy/tests/policy.spec.ts @@ -1,7 +1,7 @@ /** Event-level policy tests; no filesystem provider is needed because the plugin performs no I/O. */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsObservation, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 08d63895d0..cc6daed922 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-fs-sandbox", "description": "Sandbox-enforcing implementation of the DeepSeek Harness filesystem seam: fences write/edit by the per-call sandbox mode (read-only denies mutation, workspace-write contains it to the workspace + temp roots) while reads pass through", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/fs/fs-sandbox" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,12 +32,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-fs-local": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-fs-local": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", @@ -38,6 +45,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/fs-sandbox/src/index.ts b/packages/fs/fs-sandbox/src/index.ts index 8fa3654d62..9a0fa3104e 100644 --- a/packages/fs/fs-sandbox/src/index.ts +++ b/packages/fs/fs-sandbox/src/index.ts @@ -30,7 +30,7 @@ * @module @deepseek-ai/dsh-fs-sandbox */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LocalFileSystem } from '@deepseek-ai/dsh-fs-local' import type { Config as LocalConfig } from '@deepseek-ai/dsh-fs-local' import { FsError } from '@deepseek-ai/dsh-fs' diff --git a/packages/fs/fs-sandbox/src/invariant.ts b/packages/fs/fs-sandbox/src/invariant.ts index 93806bd519..8a42c86c90 100644 --- a/packages/fs/fs-sandbox/src/invariant.ts +++ b/packages/fs/fs-sandbox/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-fs-sandbox' diff --git a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts index 62648e1362..f6aaa0dd81 100644 --- a/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts +++ b/packages/fs/fs-sandbox/tests/fs-sandbox.spec.ts @@ -13,7 +13,7 @@ import { mkdir, mkdtemp, readFile, rm, symlink, writeFile } from 'node:fs/promis import { existsSync } from 'node:fs' import { homedir, tmpdir } from 'node:os' import { join, parse } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { FsError, FsTargetKey } from '@deepseek-ai/dsh-fs' import type { FsTarget } from '@deepseek-ai/dsh-fs' import SandboxPolicyService from '@deepseek-ai/dsh-sandbox-policy' diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index c13921729a..c9ae290b78 100644 --- a/packages/fs/fs/README.i18n.yaml +++ b/packages/fs/fs/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/fs/fs/README.md -README.md: 62d3febde82e013ace054a9e6242147c1756b0d1 -README.zh.md: 137c1e8da1014bf7dda7c4bf2e667aca6d2f51b5 +README.md: d53fe69456622e533e5ba5a96bd9dab10c188eaa +README.zh.md: 64b7d79687a0b6a0d81a5037ea6044d4a406f32b diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 62d3febde8..d53fe69456 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained text-storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. +The **`FileSystem`** (`ctx.fs`) defines the storage primitives in one execution world — resolve paths, expose canonical process paths and file URIs, test containment, read whole or streaming text, read bounded raw bytes, inspect/list metadata, write atomically, and apply a literal edit — without saying HOW. Both mutations take their version guard **optionally**, so `ctx.fs` on its own is a complete, unconstrained storage seam. This package also owns the `fs/*` policy event vocabulary the tool dispatches and the policy plugin listens for. This package owns the Service Definition and provider contract layer of the four-layer filesystem stack, split so each concern can evolve (and be swapped) independently (see [the capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md), [the filesystem capability-seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md), [the split-the-filesystem-seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md), and [the file-context event-gate Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): @@ -17,7 +17,7 @@ This package owns the Service Definition and provider contract layer of the four ## Service API (`ctx.fs`) -A backend subclasses `FileSystem` and implements eleven primitives. +A backend subclasses `FileSystem` and implements twelve primitives. | Member | Semantics | |---|---| @@ -29,6 +29,7 @@ A backend subclasses `FileSystem` and implements eleven primitives. | `lstat(path, opts?, signal?)` | Return `FsPathInfo` metadata without following the final path component when it is a symlink. This is path-shaped so consumers can reject repository-owned symlinks before `resolve` follows them into a target. | | `readText(target, signal?)` | Read the whole regular text file as one decoded string. Owns regular-file checks, UTF-8 decoding, binary/NUL rejection (`FS_NOT_TEXT`). | | `streamText(target, signal?)` | Stream the same text as decoded chunks for large files (cross-chunk UTF-8 decoding stays here); consumers that need a byte ceiling enforce it while consuming the stream. | +| `readBytes(target, signal, maxBytes)` | Read a complete regular file as raw bytes with no decoding or binary rejection. `maxBytes` is required and bounds the complete content at this seam: a known or discovered overflow fails with `FS_TOO_LARGE` instead of truncating or buffering without a bound. | | `listDir(target, signal?)` | List direct directory children in stable name order. Returns entry names, entry types, resolved child targets, and cheap metadata (`version`/file `size` when available); never reads file contents. Missing targets throw `FS_NOT_FOUND`, non-directories throw `FS_NOT_DIRECTORY`, permission failures throw `FS_PERMISSION_DENIED`, and other backend I/O failures throw `FS_IO_ERROR`. Broken/disappeared children may be returned as `other` without metadata; child permission/IO failures fail the whole listing with the same structured codes. | | `writeText(target, content, expected?, signal?)` | Atomic create/replace. `expected` is OPTIONAL: omit ⇒ unconditional create-or-overwrite; supply an `FsWriteIntent` (`createIfAbsent`/`replaceIfVersion`) to guard. `createIfAbsent` must perform a no-replace publication so a creator racing the initial probe is preserved. | | `editText(target, edit, expected?, signal?)` | Literal edit. `expected` is OPTIONAL: omit ⇒ unconditional edit of the current content; supply `{ version }` to guard (verified BEFORE matching). A missing target reports `FS_STALE_VERSION` either way. Applies and writes atomically — one mutation critical section. | @@ -47,7 +48,7 @@ This package declares three events (see the generated region of [filesystem.md]( ## Vocabulary -`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsObservation` distinguishes `{ kind: 'present', version }` from `{ kind: 'absent' }`, so a policy can separate an unseen target from confirmed absence without performing I/O. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. +`FsTargetKey` / `FsVersion` are branded opaque ids ([the branded-ids Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md)) — consumers must not parse `targetKey` or interpret `version`; only `displayPath` is for model/UI output. `FsObservation` distinguishes `{ kind: 'present', version }` from `{ kind: 'absent' }`, so a policy can separate an unseen target from confirmed absence without performing I/O. `FsWriteIntent` is the explicit GUARDED write intent (`createIfAbsent` creates a missing target and rejects an existing one with `FS_NOT_OBSERVED`; `replaceIfVersion` replaces only at the observed version, else `FS_STALE_VERSION`); omitting it from `writeText` is the third, unconditional state. `FsPathInfo` is the no-follow metadata shape that can report `symlink`, unlike target-level `FsInfo`. Failures throw `FsError` (extends `HarnessError`, [the structured error taxonomy Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)) carrying a stable `FsErrorCode` (`FS_NOT_FOUND`, `FS_NOT_DIRECTORY`, `FS_NOT_TEXT`, `FS_NOT_REGULAR_FILE`, `FS_TOO_LARGE`, `FS_PERMISSION_DENIED`, `FS_IO_ERROR`, `FS_STALE_VERSION`, `FS_NOT_OBSERVED`, `FS_AMBIGUOUS_EDIT`, `FS_EDIT_NOT_FOUND`, `FS_ABORTED`); the tool registry surfaces `{ name, code }` on `isError` results. See `src/types.ts` for the full contracts. ## Model Experience @@ -59,7 +60,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Text-only by contract** — backends reject binary/non-UTF-8 content with `FS_NOT_TEXT`; binary-safe operations are a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md). -- **Eleven primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md). +- **Text-only mutations by contract** — text reads and both mutations reject binary/non-UTF-8 content with `FS_NOT_TEXT`; `readBytes` is the one raw-byte primitive, and binary-safe mutations remain a deliberate deferral of [the tool-schemas Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md). +- **Twelve primitives only** — no delete, rename/move, copy, or watch; `listDir` is single-level, with recursion, globbing, pagination, and search out of scope per [the directory-listing Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md). - **No IO deadline** — the seam arms no timeout; cancellation is a best-effort optional `AbortSignal` per primitive (the deliberate [fs-family stance](../README.md)). - **Resolve-then-operate costs a remote backend two round-trips per tool call** — folding or caching resolution is left to such a backend. diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index 137c1e8da1..64b7d79687 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的文本存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。 +**`FileSystem`**(`ctx.fs`)定义同一个执行世界中的存储原语,包括解析路径、公开规范化进程路径与文件 URI、检查包含关系、完整或流式读取文本、有界读取原始字节、检查/列出元数据、原子写入和应用字面量编辑,但不规定实现方式。两个变更操作都**可选** 接收版本防护,因此 `ctx.fs` 本身就是完整且不受约束的存储 seam。本包还拥有由工具分派、政策插件监听的 `fs/*` 政策事件词汇。 本包是四层文件系统栈中的提供方约定层;该拆分使每个关注点可以独立演进和替换(见[能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-13-capability-seams.md)、[文件系统能力 seam Agent Note](../../../.agents/notes/implemented/architecture/2026-06-17-filesystem-capability-seam.md)、[拆分文件系统 seam Agent Note](../../../.agents/notes/implemented/simplification/2026-06-26-fsspec-style-fs-seam.md)和[文件上下文事件门禁 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-26-file-context-as-event-gate.md)): @@ -17,7 +17,7 @@ ## 服务 API(`ctx.fs`) -后端继承 `FileSystem` 并实现十一个原语。 +后端继承 `FileSystem` 并实现十二个原语。 | 成员 | 语义 | |---|---| @@ -29,6 +29,7 @@ | `lstat(path, opts?, signal?)` | 当最后一个路径组件是符号链接时,不跟随该组件,返回 `FsPathInfo` 元数据。该方法采用路径形态,使消费方能在 `resolve` 跟随仓库所有的符号链接进入目标前拒绝它。 | | `readText(target, signal?)` | 把整个普通文本文件读取为一个解码后的字符串。负责普通文件检查、UTF-8 解码和二进制/NUL 拒绝(`FS_NOT_TEXT`)。 | | `streamText(target, signal?)` | 为大文件按解码后的分片流式读取相同文本(跨分片 UTF-8 解码仍由此处负责);需要字节上限的消费方在消费流时执行该上限。 | +| `readBytes(target, signal, maxBytes)` | 把完整普通文件按原始字节读出,不做解码或二进制拒绝。`maxBytes` 为必填,在该 seam 上限制完整内容:已知或读取中发现的超限以 `FS_TOO_LARGE` 失败,而不是截断或无界缓冲。 | | `listDir(target, signal?)` | 按稳定名称顺序列出直接子项。返回条目名称、条目类型、解析后的子目标和低成本元数据(若可用则包括 `version`/文件 `size`);绝不读取文件内容。缺失目标抛出 `FS_NOT_FOUND`,非目录抛出 `FS_NOT_DIRECTORY`,权限失败抛出 `FS_PERMISSION_DENIED`,其他后端 I/O 失败抛出 `FS_IO_ERROR`。损坏/消失的子项可以作为无元数据的 `other` 返回;子项权限/I/O 失败会使用相同结构化代码使整个列表失败。 | | `writeText(target, content, expected?, signal?)` | 原子创建/替换。`expected` 是可选的:省略 ⇒ 无条件创建或覆盖;提供 `FsWriteIntent`(`createIfAbsent`/`replaceIfVersion`)⇒ 添加防护。`createIfAbsent` 必须以不替换的方式发布,使初始探测后抢先创建的文件得到保留。 | | `editText(target, edit, expected?, signal?)` | 字面量编辑。`expected` 是可选的:省略 ⇒ 无条件编辑当前内容;提供 `{ version }` ⇒ 添加防护,并在匹配之前校验。无论哪种情况,目标缺失都报告 `FS_STALE_VERSION`。应用和写入以原子方式完成,使用同一个变更临界区。 | @@ -47,7 +48,7 @@ ## 词汇 -`FsTargetKey` / `FsVersion` 是带品牌的不透明 id(见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsObservation` 区分 `{ kind: 'present', version }` 与 `{ kind: 'absent' }`,使策略无需执行 I/O 即可分辨未见目标和确认缺失。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode`(`FS_NOT_FOUND`、`FS_NOT_DIRECTORY`、`FS_NOT_TEXT`、`FS_NOT_REGULAR_FILE`、`FS_PERMISSION_DENIED`、`FS_IO_ERROR`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND`、`FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`。 +`FsTargetKey` / `FsVersion` 是带品牌的不透明 id(见[品牌 id Agent Note](../../../.agents/notes/implemented/architecture/2026-06-20-branded-ids.md));消费方不得解析 `targetKey` 或解释 `version`,只有 `displayPath` 用于模型/UI 输出。`FsObservation` 区分 `{ kind: 'present', version }` 与 `{ kind: 'absent' }`,使策略无需执行 I/O 即可分辨未见目标和确认缺失。`FsWriteIntent` 是显式的防护写入意图(`createIfAbsent` 创建缺失目标,并以 `FS_NOT_OBSERVED` 拒绝现有目标;`replaceIfVersion` 只在观察版本上替换,否则为 `FS_STALE_VERSION`);从 `writeText` 中省略该值就是第三种无条件状态。`FsPathInfo` 是可报告 `symlink` 的不跟随链接元数据形态,区别于目标级 `FsInfo`。失败会抛出 `FsError`(继承 `HarnessError`;见[结构化错误分类 Agent Note](../../../.agents/notes/implemented/architecture/2026-06-11-structured-error-taxonomy.md)),并携带稳定的 `FsErrorCode`(`FS_NOT_FOUND`、`FS_NOT_DIRECTORY`、`FS_NOT_TEXT`、`FS_NOT_REGULAR_FILE`、`FS_TOO_LARGE`、`FS_PERMISSION_DENIED`、`FS_IO_ERROR`、`FS_STALE_VERSION`、`FS_NOT_OBSERVED`、`FS_AMBIGUOUS_EDIT`、`FS_EDIT_NOT_FOUND`、`FS_ABORTED`);工具注册表公开 `{ name, code }`,并将其附在 `isError` 结果上。完整约定见 `src/types.ts`。 ## 模型体验 @@ -59,7 +60,7 @@ ## 已知限制与延期工作 -- **约定只支持文本**:后端以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;二进制安全操作是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。 -- **只有十一个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。 +- **变更操作约定只支持文本**:文本读取和两个变更操作都以 `FS_NOT_TEXT` 拒绝二进制/非 UTF-8 内容;`readBytes` 是唯一的原始字节原语,二进制安全的变更操作仍是[工具 schema Agent Note](../../../.agents/notes/implemented/feature/2026-06-17-filesystem-tool-schemas.md)有意延期的工作。 +- **只有十二个原语**:没有删除、重命名/移动、复制或监视;`listDir` 只支持一层,递归、glob、分页和搜索不在范围内,见[目录列出 Agent Note](../../../.agents/notes/archived/architecture/2026-07-03-filesystem-directory-listing-seam.md)。 - **没有 I/O deadline**:该 seam 不启动超时;取消只是每个原语上尽力而为的可选 `AbortSignal`(见有意采用的 [fs 能力族立场](../README.md))。 - **先解析后操作使远程后端每次工具调用需要两次往返**:折叠或缓存解析由这种后端自行决定。 diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index 14989c2f77..e9779e9d96 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-fs", "description": "Abstract filesystem capability seam (ctx.fs) for the DeepSeek Harness — vocabulary types, the FileSystem service (text IO + optional version-guarded atomic mutations), and the fs/* policy event vocabulary", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/fs/fs" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/fs/src/index.ts b/packages/fs/fs/src/index.ts index 0be94dccc4..bcd78cd23f 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-fs */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { FsDirEntry, @@ -41,7 +41,7 @@ export type { FsWriteOutcome, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { fs: FileSystem } @@ -186,6 +186,18 @@ export abstract class FileSystem extends Service { */ abstract streamText(target: FsTarget, signal?: AbortSignal): Promise> + /** + * Read the whole regular file as raw bytes with no decoding or binary + * rejection. The bound lives at this seam so a backend can never buffer an + * unbounded file: a target known or discovered to exceed `maxBytes` fails + * with `FS_TOO_LARGE` instead of returning a truncated result. + * @param target - the resolved target to read. + * @param signal - aborts the read. + * @param maxBytes - inclusive byte cap on the complete content. + * @returns the full raw content, at most `maxBytes` long. + */ + abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise + /** * List direct children of a directory in stable name order. Returns resolved * child targets plus cheap metadata only; never reads file contents. diff --git a/packages/fs/fs/src/invariant.ts b/packages/fs/fs/src/invariant.ts index 62fec9a53c..ec5a22e021 100644 --- a/packages/fs/fs/src/invariant.ts +++ b/packages/fs/fs/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned filesystem event-data invariants. @module @deepseek-ai/dsh-fs/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { FsObservation, FsTarget } from './types.ts' diff --git a/packages/fs/fs/src/types.ts b/packages/fs/fs/src/types.ts index ddef6f117d..a96f4c1a67 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -177,6 +177,7 @@ export type FsErrorCode = | 'FS_NOT_DIRECTORY' | 'FS_NOT_TEXT' | 'FS_NOT_REGULAR_FILE' + | 'FS_TOO_LARGE' | 'FS_PERMISSION_DENIED' | 'FS_SANDBOX_DENIED' | 'FS_IO_ERROR' diff --git a/packages/fs/fs/tests/invariant.spec.ts b/packages/fs/fs/tests/invariant.spec.ts index eecdea8766..a63606781a 100644 --- a/packages/fs/fs/tests/invariant.spec.ts +++ b/packages/fs/fs/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsTarget } from '@deepseek-ai/dsh-fs' import * as FsInvariant from '@deepseek-ai/dsh-fs/invariant' diff --git a/packages/fs/fs/tests/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 2340871304..8b9d23f819 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { FileSystem, FsError, FsTargetKey, FsVersion } from '@deepseek-ai/dsh-fs' import type { FsDirEntry, @@ -50,6 +50,13 @@ class FakeFileSystem extends FileSystem { const content = await this.readText(target) return (async function* () { yield content })() } + override async readBytes(target: FsTarget, _signal: AbortSignal | undefined, maxBytes: number): Promise { + const bytes = new TextEncoder().encode(await this.readText(target)) + if (bytes.length > maxBytes) { + throw new FsError(`too large: ${target.displayPath}`, 'FS_TOO_LARGE') + } + return bytes + } override async listDir(target: FsTarget): Promise { if (target.targetKey !== 'skills') throw new FsError(`not a directory: ${target.displayPath}`, 'FS_NOT_DIRECTORY') return [ @@ -112,6 +119,16 @@ describe('FileSystem provider seam', () => { expect(streamed).toBe(await fs.readText(target)) }) + it('readBytes returns raw content and enforces the byte cap with FS_TOO_LARGE', async () => { + const ctx = new Context() + await ctx.plugin(FakeFileSystem) + const fs = ctx.fs as FakeFileSystem + fs.files.set('a.bin', 'hi') + const target = await fs.resolve('a.bin') + expect(await fs.readBytes(target, undefined, 2)).toEqual(new TextEncoder().encode('hi')) + await expect(fs.readBytes(target, undefined, 1)).rejects.toMatchObject({ code: 'FS_TOO_LARGE' }) + }) + it('listDir returns child entry targets without reading file content', async () => { const ctx = new Context() await ctx.plugin(FakeFileSystem) diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index c0fb5d49d3..ca56b0198a 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-fs-search", "description": "Model-facing filesystem discovery tools (glob, grep) backed by the packaged ripgrep binary (@vscode/ripgrep)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/fs/tool-fs-search" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -26,19 +33,19 @@ "license": "BSD-3-Clause", "dependencies": { "@vscode/ripgrep": "^1.18.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-retention": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-spill": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-retention": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-spill": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -52,6 +59,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/tool-fs-search/src/glob.ts b/packages/fs/tool-fs-search/src/glob.ts index 3eeea9c75c..50a432b0bb 100644 --- a/packages/fs/tool-fs-search/src/glob.ts +++ b/packages/fs/tool-fs-search/src/glob.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-tool-fs-search/glob */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { sep } from 'node:path' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' diff --git a/packages/fs/tool-fs-search/src/grep.ts b/packages/fs/tool-fs-search/src/grep.ts index c7e8ec7ee4..81f76dfa09 100644 --- a/packages/fs/tool-fs-search/src/grep.ts +++ b/packages/fs/tool-fs-search/src/grep.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-tool-fs-search/grep */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, SearchResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { RetainedItems } from '@deepseek-ai/dsh-retention' diff --git a/packages/fs/tool-fs-search/src/index.ts b/packages/fs/tool-fs-search/src/index.ts index cf0a8db066..1f1d86556b 100644 --- a/packages/fs/tool-fs-search/src/index.ts +++ b/packages/fs/tool-fs-search/src/index.ts @@ -26,8 +26,8 @@ * @module @deepseek-ai/dsh-tool-fs-search */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { GLOB_MAX_RESULTS, applyGlobTool } from './glob.ts' import { GREP_MAX_LINE_BYTES, GREP_MAX_MATCHES, applyGrepTool } from './grep.ts' diff --git a/packages/fs/tool-fs-search/src/invariant.ts b/packages/fs/tool-fs-search/src/invariant.ts index f7f206896d..26d14775ba 100644 --- a/packages/fs/tool-fs-search/src/invariant.ts +++ b/packages/fs/tool-fs-search/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs-search' diff --git a/packages/fs/tool-fs-search/src/search-core.ts b/packages/fs/tool-fs-search/src/search-core.ts index 854c593190..ad8693a67c 100644 --- a/packages/fs/tool-fs-search/src/search-core.ts +++ b/packages/fs/tool-fs-search/src/search-core.ts @@ -20,7 +20,7 @@ */ import { isAbsolute, relative, sep } from 'node:path' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { ItemRetainer, TextRetainer } from '@deepseek-ai/dsh-retention' import type { RetainedItems } from '@deepseek-ai/dsh-retention' diff --git a/packages/fs/tool-fs-search/src/surface.ts b/packages/fs/tool-fs-search/src/surface.ts index 78bdd6cc42..0db8887c0c 100644 --- a/packages/fs/tool-fs-search/src/surface.ts +++ b/packages/fs/tool-fs-search/src/surface.ts @@ -1,6 +1,6 @@ /** Shared surface-only post-policy selection for search result spill. @module dsh-tool-fs-search/surface */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { JsonValue, PostToolDecision, ToolDefinition, ToolExecution, ToolExecutionResult } from '@deepseek-ai/dsh-tools' /** diff --git a/packages/fs/tool-fs-search/tests/integration.spec.ts b/packages/fs/tool-fs-search/tests/integration.spec.ts index dc7a88b30f..5087fc96f0 100644 --- a/packages/fs/tool-fs-search/tests/integration.spec.ts +++ b/packages/fs/tool-fs-search/tests/integration.spec.ts @@ -14,7 +14,7 @@ import { existsSync } from 'node:fs' import { mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' diff --git a/packages/fs/tool-fs-search/tests/load-path.spec.ts b/packages/fs/tool-fs-search/tests/load-path.spec.ts index 1d1e348c09..7dcd1aea6b 100644 --- a/packages/fs/tool-fs-search/tests/load-path.spec.ts +++ b/packages/fs/tool-fs-search/tests/load-path.spec.ts @@ -15,8 +15,8 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/fs/tool-fs-search/tests/rg-path.spec.ts b/packages/fs/tool-fs-search/tests/rg-path.spec.ts index 52888a3453..3ad75edaaf 100644 --- a/packages/fs/tool-fs-search/tests/rg-path.spec.ts +++ b/packages/fs/tool-fs-search/tests/rg-path.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import { resolveRgPath, runRipgrep } from '@deepseek-ai/dsh-tool-fs-search' diff --git a/packages/fs/tool-fs-search/tests/tools.spec.ts b/packages/fs/tool-fs-search/tests/tools.spec.ts index c02e4f93db..c4ea89345f 100644 --- a/packages/fs/tool-fs-search/tests/tools.spec.ts +++ b/packages/fs/tool-fs-search/tests/tools.spec.ts @@ -11,7 +11,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { join, sep } from 'node:path' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 96785be516..193fd1a627 100644 --- a/packages/fs/tool-fs/README.i18n.yaml +++ b/packages/fs/tool-fs/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/fs/tool-fs/README.md -README.md: 27b53aca50f470fe9ead4da27d87328264440ff7 -README.zh.md: 5bcf9c471d933702c46d50c56c9539cb9eede3ca +README.md: 7e334f886747cd8dc566a572c699cd80c7cf62fe +README.zh.md: b5eb5ae38aba049d77375d31d1509342a23f13fc diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 27b53aca50..7e334f8867 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -2,17 +2,20 @@ English | [中文](README.zh.md) -The **model-facing filesystem tools** — `read`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations. +The **model-facing filesystem tools** — `read`, `read_image`, `write`, `edit` — and their **executor**. This is the consumer layer of the filesystem stack: it owns tool names, JSON schemas, argument validation, prompt sections, **read windowing**, and result formatting. It reads/writes/edits through the `ctx.fs` provider contract ([`@deepseek-ai/dsh-fs`](../fs)) **directly**. The freshness/observation policy is contributed by a separate plugin ([`@deepseek-ai/dsh-fs-policy`](../fs-policy)) through the `fs/*` event gate; the tool is not method-coupled to it. Under a confining provider, the shared sandbox-policy service is required for per-session execution and the tool exposes escalation for filesystem mutations. ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) -await ctx.plugin(ToolFs) // this package — registers read/write/edit +await ctx.plugin(LocalAttachmentStore, { dshHome }) // optional — enables durable read_image results +await ctx.plugin(ToolFs) // this package — read/write/edit, plus read_image with attachments ``` `@deepseek-ai/dsh-fs-policy` is **optional**: omit it and the tools run against the bare provider (unconditional write/overwrite/edit, no observed-state). A deployment that loads these tools is expected to also load it, so the behavior is read-before-write/edit. +`read_image` registers only while a durable `ctx.attachments` service is mounted — without one the deployment cannot commit image bytes, so the tool never appears. Execution additionally requires the exact routed model to declare `image` input (resolved through `ctx.llm.resolveModelInfo` from the session's latest request header, falling back to agent options); an unknown or text-only route gets a refusal result before any filesystem I/O, so a text route's durable history stays free of image blocks. + ## Config All keys are optional; the defaults are the shipped read caps. @@ -29,18 +32,20 @@ All keys are optional; the defaults are the shipped read caps. | Tool | Arguments | Behavior | |---|---|---| | `read` | `file_path`, `offset?`, `limit?` | Line-numbered UTF-8 content with a pagination footer. `offset` is 1-based; `limit` defaults to and caps at the configured `readLimit` (2000). | +| `read_image` | `file_path` | Reads a PNG/JPEG/WebP/GIF file through the bounded byte seam, persists it through `ctx.attachments.saveImage`, and returns an image block beside a small metadata envelope. It succeeds only when the exact routed model declares image input. | | `write` | `file_path`, `content` | Create or fully replace a file. With the policy plugin: overwriting an existing file requires a prior `read` at the unchanged version; creating a new file does not. Without it: unconditional. | | `edit` | `file_path`, non-empty `old_string`, `new_string`, `replace_all?` | Literal replacement; unique match required unless `replace_all` is true. With the policy plugin: requires a prior `read` (any window) and the file unchanged since. Without it: unconditional. | Field names are snake_case to match Claude Code and existing harness tool schemas. -Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted. +Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], totalLines }`, `read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name? } }`, `write` → `{ path, operation: 'create' | 'update', before: string | null, after }`, and `edit` → `{ path, before, after }`. Native renderers preserve the line-numbered read and mutation acknowledgements below. `write`/`edit` derive replayable diff-card metadata, and `read` derives a replayable read-card window `{ path, offset, lines, totalLines, lang? }`, from these canonical values; the canonical values themselves are execution-local and are not added to `tool/result`, only the derived presentation metadata is persisted. ## The tool is the executor; policy is an event gate The tools do **not** inject a policy service or inspect any cache. Each tool resolves the path via `ctx.fs.resolve(path, { cwd, signal })` — passing the calling agent's session cwd (`exec.agent.session.header.cwd`) so a relative path resolves against the session's workspace, matching `dsh-tool-bash`, and forwarding tool cancellation through resolution (see [the per-session cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md)) — then: - **read** — one `ctx.fs.stat` (type + size routing + version), then `readText`/`streamText`, then builds the line window, then emits `fs/observed` with a plain `ctx.emit`. (1 stat.) +- **read_image** — validates the argument, extension, attachment availability, deployment media types, and the image-capable route before any I/O; then one `ctx.fs.stat` (recording an `absent` observation for a missing target, like `read`), a bounded `ctx.fs.readBytes` capped at the smaller of `imageLimits.maxImageBytes` and `imageLimits.maxMessageImageBytes` (the result is one message carrying one image), `attachments.saveImage` (content-addressed, so the image block references a durably committed object by the time `tool/result` is appended), and finally `fs/observed`. (1 stat.) - **write** — `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.writeText(target, content, intent)`, then `fs/observed`. (0 stat.) - **edit** — `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` for the optional guard, then `ctx.fs.editText(target, edit, intent)`, then `fs/observed`. (0 stat.) @@ -50,11 +55,11 @@ When `ctx.fs.sandboxMode` reports confinement, write/edit advertise `sandbox_per ## `fs/observed` is fire-and-forget -`fs/observed` fires AFTER the read/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. +`fs/observed` fires AFTER the read/read_image/write/edit already succeeded, via a plain `ctx.emit`. A listener is contractually a synchronous, side-effect-only recorder (`@deepseek-ai/dsh-fs-policy`'s is a `WeakMap.set`); the tool does not guard the emit, so a listener that throws would surface as the tool's `isError` result — async or fallible observation does not belong on this event. `read` opts into concurrent scheduling because its only mutation is the synchronous version recorder. Recorder races fail closed when a later `write` or `edit` re-checks the version under its target lock; both mutation tools remain exclusive. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md). -The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. +The package root exports only the Cordis plugin contract (`name`, `inject`, `Config`, and `apply`). Read rendering (line windowing + output formatting) lives in `src/read-render.ts` (Cordis-free, independently unit-tested); `src/read.ts`/`read-image.ts`/`write.ts`/`edit.ts` are the tool executors and `src/index.ts` composes them. ## Model Experience @@ -94,7 +99,7 @@ Prefix-stable while the plugin scope and guidance text are unchanged. Tool restr #### What the model sees -The model sees the generated [`read`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. Scoped tool restrictions can remove any definition for one agent. +The model sees the generated [`read`, `read_image`, `write`, and `edit` schemas](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs), with snake_case arguments. `read_image` appears only while a durable attachment store is mounted; the schema itself is route-independent, and the strict gate refuses at execution. Scoped tool restrictions can remove any definition for one agent. #### Token effect @@ -118,6 +123,20 @@ Read output is capped by `readLimit`, `readMaxLineLength`, and `readMaxBytes`; t Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. +### Image read result + +#### What the model sees + +A successful `read_image` returns ``, `image`, and a `` envelope naming the media type, dimensions, and byte size, followed by the image itself as a native image block. The session log stores only the durable `sha256:` attachment reference; the routed provider re-reads and digest-verifies the bytes on each request. + +#### Token effect + +The image is billed on every later request until compaction. Each call is independently bounded by the attachment store's `maxImageBytes`/`maxImagePixels`; repeated successful calls accumulate history, and content addressing deduplicates only the stored bytes, not the per-request token cost. + +#### KV Cache effect + +Append-only; newly visible content follows the reusable request prefix and does not invalidate existing KV-cache entries. + ### Write and edit results #### What the model sees @@ -136,7 +155,7 @@ Append-only; newly visible content follows the reusable request prefix and does #### What the model sees -Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, and `offset is out of range for "" ( lines)`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. +Failures are normalized as `Error: `. This package's stable validation and read messages are `file_path must be a non-empty string`, `limit must be less than or equal to `, `old_string must be a non-empty string`, `old_string and new_string must differ`, `cannot read "": not found`, `cannot read "": not a regular file`, `offset is out of range for "" ( lines)`, `cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`, `cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`, and the mismatch repair `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`; provider and policy templates are quoted in their package READMEs. Guarded-mutation failures additionally carry their recovery instruction in the message, appended by this package's model-facing error wrapper: `FS_STALE_VERSION` gets `— re-read the file, then retry`, and `FS_NOT_OBSERVED` gets `— read the file, then retry`; the structured code is preserved. After that reread confirms absence, edit reports `FS_NOT_FOUND` instead of repeating a stale remedy, while write uses guarded creation. #### Token effect @@ -149,5 +168,8 @@ Append-only; newly visible content follows the reusable request prefix and does ## Known Limitations and Deferred Work - **No model-facing directory listing ships** — `ctx.fs.listDir` serves provider code such as skill discovery, while the sibling [`dsh-tool-fs-search`](../tool-fs-search/) package supplies ripgrep-backed `glob` and `grep` rather than extending the filesystem seam. -- **`read` handles UTF-8 text files only** — binary-safe reads and PDF/image/multimodal content are deferred; a directory target is `FS_NOT_REGULAR_FILE`. +- **`read` handles UTF-8 text files only** — images use the separate extension-routed `read_image` tool; PDF, audio, and video remain deferred. A directory target is `FS_NOT_REGULAR_FILE`. +- **The route gate races a concurrent model switch** — `read_image` checks the latest routed model at execution; a switch committed between that check and the next request can leave an image block on a route that rejects image content. The Web host already refuses switching an image-bearing session to a text-only model; other front doors own their equivalent guard. +- **Extension-declared media type** — the extension selects the declared type and the attachment store's magic-byte validation stays authoritative; a correctly formatted image under a wrong extension is refused with the rename remedy rather than sniffed. +- **No inline image preview on the tool-result card** — UI surfaces render the image result generically (the durable reference, not pixels); inline rendering is deferred to the UI packages. - **No timeout surface** — `read`/`write`/`edit` take no timeout argument and declare no `timeout-policy` budget; cancellation rides `exec.signal` only ([provider rationale](../README.md#no-timeouts-on-file-io)). diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index 5bcf9c471d..b5eb5ae38a 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -2,17 +2,20 @@ [English](README.md) | 中文 -**面向模型的文件系统工具**(`read`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。 +**面向模型的文件系统工具**(`read`、`read_image`、`write`、`edit`)及其**执行器**。这是文件系统栈的消费方层:拥有工具名称、JSON Schema、参数校验、提示词段、**读取窗口逻辑**和结果格式化。它**直接**通过 `ctx.fs` 提供方约定([`@deepseek-ai/dsh-fs`](../fs))读取/写入/编辑。新鲜度/观察策略由独立插件([`@deepseek-ai/dsh-fs-policy`](../fs-policy))通过 `fs/*` 事件门禁贡献;工具不与其方法耦合。使用施加沙箱限制的提供方时,逐会话执行需要共享沙箱策略服务,工具还会为文件系统变更提供升权路径。 ```ts ignore-check // Default deployment: a ctx.fs provider, the policy plugin, then the tools. await ctx.plugin(LocalFileSystem, { cwd: process.cwd() }) // @deepseek-ai/dsh-fs-local await ctx.plugin(FsPolicy) // @deepseek-ai/dsh-fs-policy (policy gate) -await ctx.plugin(ToolFs) // this package — registers read/write/edit +await ctx.plugin(LocalAttachmentStore, { dshHome }) // optional — enables durable read_image results +await ctx.plugin(ToolFs) // this package — read/write/edit, plus read_image with attachments ``` `@deepseek-ai/dsh-fs-policy` 是**可选的**:省略时,工具直接使用裸提供方(无条件写入/覆盖/编辑,无已观察状态)。加载这些工具的部署也应加载该插件,从而提供写入/编辑前读取行为。 +`read_image` 只在持久 `ctx.attachments` 服务已挂载时注册:没有它,部署无法持久提交图像字节,工具就不会出现。执行时还要求确切路由的模型声明 `image` 输入(通过 `ctx.llm.resolveModelInfo` 从会话最新请求 header 解析,缺失时回退到 agent 选项);未知或纯文本路由在任何文件系统 I/O 之前就得到拒绝结果,因此文本路由的持久历史不会出现图像块。 + ## 配置 所有键均为可选;默认值是随产品交付的读取上限。 @@ -29,18 +32,20 @@ await ctx.plugin(ToolFs) // this package — re | 工具 | 参数 | 行为 | |---|---|---| | `read` | `file_path`、`offset?`、`limit?` | 带行号的 UTF-8 内容和分页 footer。`offset` 从 1 开始;`limit` 默认为配置的 `readLimit`(2000),上限也为该值。 | +| `read_image` | `file_path` | 通过有界字节 seam 读取 PNG/JPEG/WebP/GIF 文件,经 `ctx.attachments.saveImage` 持久保存,并在小型元数据信封旁返回图像块。只有确切路由的模型声明图像输入时才会成功。 | | `write` | `file_path`、`content` | 创建文件或完整替换文件。有策略插件时:覆盖现有文件要求先在未变版本上执行 `read`;创建新文件不需要。没有插件时:无条件执行。 | | `edit` | `file_path`、非空 `old_string`、`new_string`、`replace_all?` | 字面量替换;除非 `replace_all` 为 true,否则要求唯一匹配。有策略插件时:要求先执行 `read`(任何窗口),且文件此后未变。没有插件时:无条件执行。 | 字段名使用 snake_case,与 Claude Code 和现有 harness 工具 schema 一致。 -规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。 +规范成功值分别为:`read` → `{ path, offset, lines: [{ number, text }], totalLines }`,`read_image` → `{ path, image: { attachmentId, mediaType, bytes, width, height, name? } }`,`write` → `{ path, operation: 'create' | 'update', before: string | null, after }`,`edit` → `{ path, before, after }`。原生渲染器会保留下方带行号的读取结果和变更确认。`write`/`edit` 从这些规范值派生可回放的 diff 卡片元数据,`read` 派生可回放的读取卡片窗口 `{ path, offset, lines, totalLines, lang? }`;规范值本身仅限于本次执行,不会添加到 `tool/result`,只有派生出的呈现元数据会被持久化。 ## 工具就是执行器;策略是事件门禁 工具**不**注入策略服务,也不检查任何缓存。每个工具通过 `ctx.fs.resolve(path, { cwd, signal })` 解析路径;它会传入调用 agent(智能体)的会话 cwd(`exec.agent.session.header.cwd`),使相对路径以会话工作区为基准解析并与 `dsh-tool-bash` 一致,同时把工具取消转发到解析过程(见[每会话 cwd Agent Note](../../../.agents/notes/implemented/architecture/2026-07-02-fs-per-session-cwd.md))。随后执行: - **read**:一次 `ctx.fs.stat`(用于类型、大小路由和版本),随后调用 `readText`/`streamText`,构建行窗口,再发出 `fs/observed`,使用普通 `ctx.emit`。(1 次 stat。) +- **read_image**:在任何 I/O 之前校验参数、扩展名、附件可用性、部署接受的媒体类型和图像路由;随后一次 `ctx.fs.stat`(目标缺失时与 `read` 一样记录 `absent` 观察)、以 `imageLimits.maxImageBytes` 与 `imageLimits.maxMessageImageBytes` 中较小者为上限的有界 `ctx.fs.readBytes`(结果是携带一张图像的一条消息)、`attachments.saveImage`(内容寻址,因此在 `tool/result` 事件追加时图像块引用的对象已持久提交),最后发出 `fs/observed`。(1 次 stat。) - **write**:调用 `ctx.waterfall('fs/write-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.writeText(target, content, intent)`,再发出 `fs/observed`。(0 次 stat。) - **edit**:调用 `ctx.waterfall('fs/edit-intent', target, exec, () => undefined)` 取得可选防护,然后调用 `ctx.fs.editText(target, edit, intent)`,再发出 `fs/observed`。(0 次 stat。) @@ -50,11 +55,11 @@ await ctx.plugin(ToolFs) // this package — re ## `fs/observed` 发后即忘 -`fs/observed` 在读取/写入/编辑已经成功之后,通过普通 `ctx.emit` 发出。监听器的约定是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。 +`fs/observed` 在 read/read_image/write/edit 已经成功之后,通过普通 `ctx.emit` 发出。监听器的约定是同步且只有副作用的记录器(`@deepseek-ai/dsh-fs-policy` 使用 `WeakMap.set`);工具不保护这次发出,因此监听器抛出会作为工具的 `isError` 结果出现。异步或可能失败的观察不属于该事件。 `read` 允许并发调度,因为其唯一变更是同步版本记录器。稍后的 `write` 或 `edit` 会在目标锁内重新检查版本,因此记录器竞态会以拒绝方式关闭;两个变更工具仍保持互斥。见[并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 -包根目录只导出 Cordis 插件约定(`name`、`inject`、`Config` 和 `apply`)。读取渲染(行窗口与输出格式化)位于 `src/read-render.ts`(不依赖 Cordis,单独进行单元测试);`src/read.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。 +包根目录只导出 Cordis 插件约定(`name`、`inject`、`Config` 和 `apply`)。读取渲染(行窗口与输出格式化)位于 `src/read-render.ts`(不依赖 Cordis,单独进行单元测试);`src/read.ts`/`read-image.ts`/`write.ts`/`edit.ts` 是工具执行器,`src/index.ts` 负责组合。 ## 模型体验 @@ -94,7 +99,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -模型会看到已生成的 [`read`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。作用域工具限制可以为某个 agent 移除任一定义。 +模型会看到已生成的 [`read`、`read_image`、`write` 和 `edit` schema](../../../docs/tool-catalog.md#deepseek-aidsh-tool-fs),参数使用 snake_case。`read_image` 只在持久附件存储已挂载时出现;schema 本身与路由无关,严格门禁在执行时拒绝。作用域工具限制可以为某个 agent 移除任一定义。 #### Token 影响 @@ -118,6 +123,20 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces 仅追加;新增可见内容位于可复用请求前缀之后,不会使现有 KV Cache 条目失效。 +### 图像读取结果 + +#### 模型看到的内容 + +成功的 `read_image` 返回 ``、`image` 和写明媒体类型、尺寸与字节数的 `` 信封,随后是作为原生图像块的图像本身。会话日志只存储持久的 `sha256:` 附件引用;路由到的提供方在每次请求时重新读取并校验字节摘要。 + +#### Token 影响 + +图像在之后每次请求中都会计费,直到压缩。每次调用都独立受附件存储的 `maxImageBytes`/`maxImagePixels` 约束;重复成功调用会在历史中累积,内容寻址只去重存储的字节,不去重每次请求的 token 成本。 + +#### KV Cache 影响 + +仅追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV 缓存条目失效。 + ### 写入与编辑结果 #### 模型看到的内容 @@ -136,7 +155,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces #### 模型看到的内容 -失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file` 和 `offset is out of range for "" ( lines)`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;write 则使用带防护的创建。 +失败会规范化为 `Error: `。本包稳定的校验和读取消息是 `file_path must be a non-empty string`、`limit must be less than or equal to `、`old_string must be a non-empty string`、`old_string and new_string must differ`、`cannot read "": not found`、`cannot read "": not a regular file`、`offset is out of range for "" ( lines)`、`cannot read "": read_image only accepts PNG/JPEG/WebP/GIF paths`、`cannot read "" as an image: model "" does not declare image input; switch to an image-capable model to read images`,以及类型不匹配的修复消息 `cannot read "": the extension declares , but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`;提供方和策略模板在各自包的 README 中逐字列出。防护变更失败还会在消息中携带恢复指令,由本包面向模型的错误包装追加:`FS_STALE_VERSION` 追加 `— re-read the file, then retry`,`FS_NOT_OBSERVED` 追加 `— read the file, then retry`;结构化错误码保持不变。该次重新读取确认缺失后,edit 会报告 `FS_NOT_FOUND`,而不会重复陈旧恢复指令;write 则使用带防护的创建。 #### Token 影响 @@ -149,5 +168,8 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces ## 已知限制与暂缓事项 - **未交付面向模型的目录列表工具**:`ctx.fs.listDir` 服务于 skill(技能)发现等提供方代码,同级 [`dsh-tool-fs-search`](../tool-fs-search/) 包则提供基于 ripgrep 的 `glob` 与 `grep`,而不是扩展文件系统 seam。 -- **`read` 只处理 UTF-8 文本文件**:二进制安全读取和 PDF/图像/多模态内容均延期处理;目录目标为 `FS_NOT_REGULAR_FILE`。 +- **`read` 只处理 UTF-8 文本文件**:图像使用独立的、按扩展名路由的 `read_image` 工具;PDF、音频和视频仍延期处理。目录目标为 `FS_NOT_REGULAR_FILE`。 +- **路由门禁与并发模型切换存在竞态**:`read_image` 在执行时检查最新路由的模型;在该检查与下一次请求之间提交的切换,可能让图像块落在拒绝图像内容的路由上。Web 宿主已拒绝把含图像的会话切到纯文本模型;其他前端拥有各自的等价防护。 +- **媒体类型按扩展名声明**:扩展名选择声明类型,附件存储的魔数校验保持权威;扩展名错误但格式正确的图像会得到改名修复提示,而不是被嗅探接受。 +- **工具结果卡片没有内嵌图像预览**:UI 表面以通用形式渲染图像结果(持久引用而非像素);内嵌渲染延后到 UI 包处理。 - **没有超时接口**:`read`/`write`/`edit` 不接受超时参数,也不声明 `timeout-policy` 预算;取消只通过 `exec.signal` 传递(见[提供方理由](../README.md#no-timeouts-on-file-io))。 diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 8af8bc77ba..65db543d26 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-fs", "description": "Model-facing filesystem tools (read, write, edit) over the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/fs/tool-fs" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -26,24 +33,26 @@ "license": "BSD-3-Clause", "dependencies": { "diff": "^9.0.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", + "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", @@ -56,6 +65,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/tool-fs/src/edit.ts b/packages/fs/tool-fs/src/edit.ts index a7b82bdd06..e74f507f3d 100644 --- a/packages/fs/tool-fs/src/edit.ts +++ b/packages/fs/tool-fs/src/edit.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-tool-fs/src/edit */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-fs' diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index a4c96d606b..6255a5d3c1 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,16 +1,17 @@ /** - * Model-facing read, write, and edit tools over `ctx.fs`. This package owns schemas, validation, + * Model-facing read, read_image, write, and edit tools over `ctx.fs`. This package owns schemas, validation, * read windows, formatting, and observation events, never a concrete provider. An optional * event policy supplies mutation guards; without one the tools use unconditional provider calls. * @module @deepseek-ai/dsh-tool-fs */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-user-approval' import { applyReadTool, READ_LIMIT, STREAM_MIN_SIZE } from './read.ts' import { applyWriteTool } from './write.ts' import { applyEditTool } from './edit.ts' +import { applyReadImageTool } from './read-image.ts' import { READ_MAX_BYTES, READ_MAX_LINE_LENGTH } from './read-render.ts' import { FsSandboxSurface } from './sandbox.ts' @@ -49,7 +50,7 @@ function assertPositiveInteger(name: string, value: number): void { } } -/** Register the full `read`/`write`/`edit` filesystem tool suite. */ +/** Register the full `read`/`write`/`edit` filesystem tool suite, plus `read_image` while `attachments` is mounted. */ export function apply(ctx: Context, config: Config): void { // schemastery (Config) has already filled every defaulted field. const resolved = config as ResolvedConfig @@ -63,6 +64,12 @@ export function apply(ctx: Context, config: Config): void { maxBytes: resolved.readMaxBytes, streamMinSize: resolved.readStreamMinSize, }) + // read_image is composition-conditional: without a mounted attachment store + // the deployment cannot durably commit image bytes, so the tool never + // registers; the execute body keeps a defensive re-check for direct callers. + ctx.inject(['attachments'], (imageCtx) => { + applyReadImageTool(imageCtx) + }) // One escalation surface shared by both mutating tools: advertisement gating, // per-call policy resolution, and denial-marker mapping, all keyed off whether // the mounted ctx.fs confines (ctx.fs.sandboxMode). diff --git a/packages/fs/tool-fs/src/invariant.ts b/packages/fs/tool-fs/src/invariant.ts index eaa2485c06..8cbbe4f423 100644 --- a/packages/fs/tool-fs/src/invariant.ts +++ b/packages/fs/tool-fs/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-fs' diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts new file mode 100644 index 0000000000..85f481bf9f --- /dev/null +++ b/packages/fs/tool-fs/src/read-image.ts @@ -0,0 +1,231 @@ +/** + * The model-facing `read_image` tool: reads a PNG/JPEG/WebP/GIF file, durably + * commits its bytes through the attachment service (the same lifecycle as a + * user-uploaded image), and returns an image block so the image enters model + * context from the next request onward. + * + * The route gate is deliberately stricter than the host upload preflight: a + * tool result enters durable session history, so emitting an image on a route + * that cannot carry it would break that route's continuation. Unknown + * capability therefore refuses instead of relying on the adapter guard. + * @module @deepseek-ai/dsh-tool-fs/src/read-image + */ + +import { basename, extname } from 'node:path' +import type { Context } from '@deepseek-ai/cordis' +import { AttachmentError, AttachmentId } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentRef, ImageMediaType } from '@deepseek-ai/dsh-attachment' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import { defineTool } from '@deepseek-ai/dsh-tools' +import type { GenericCallView, ToolExecution } from '@deepseek-ai/dsh-tools' +import type {} from '@deepseek-ai/dsh-fs' +import { resolveRegularReadTarget } from './read-target.ts' + +/** Extensions `read_image` accepts; magic-byte validation at the attachment service stays authoritative. */ +const IMAGE_EXTENSIONS: Readonly> = { + '.png': 'image/png', + '.jpg': 'image/jpeg', + '.jpeg': 'image/jpeg', + '.webp': 'image/webp', + '.gif': 'image/gif', +} + +/** The canonical outcome declared by the `read_image` output schema. */ +export interface ImageReadValue { + path: string + image: { + attachmentId: string + mediaType: ImageMediaType + bytes: number + width: number + height: number + name?: string + } +} + +/** + * Map a model-supplied path to its declared image media type by extension. + * @param filePath - the raw `file_path` argument (not yet resolved). + * @returns the declared media type, or undefined when the path does not claim an image. + */ +export function imageMediaTypeForPath(filePath: string): ImageMediaType | undefined { + return IMAGE_EXTENSIONS[extname(filePath).toLowerCase()] +} + +/** + * Enforce the strict image-capability gate for the calling route. Resolves the + * session's latest routed provider/model (request header config, then agent + * options) and requires the exact resolved route to declare `image` input explicitly. + * @param ctx - the plugin context used to resolve the optional `llm` service. + * @param exec - the tool-execution context supplying the calling agent. + * @param requestedPath - the raw, not-yet-resolved path rendered in refusal messages. + */ +export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, requestedPath: string): Promise { + const routed = exec.agent?.session.requestHeader()?.config + const provider = routed?.provider ?? exec.agent?.options.provider + const model = routed?.model ?? exec.agent?.options.model + const llm = ctx.get('llm') + if (provider === undefined || model === undefined || llm === undefined) { + throw new Error(`cannot read "${requestedPath}" as an image: the current model route could not be resolved`) + } + const active = await llm.resolveModelInfo(provider, model, exec.signal) + if (active.inputModalities === undefined || !active.inputModalities.includes('image')) { + throw new Error(`cannot read "${requestedPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`) + } +} + +/** + * Re-brand a canonical image outcome into the durable attachment reference an + * `ImageBlock` carries. + * @param image - the canonical image metadata from the output schema. + * @returns the branded attachment reference. + */ +export function imageRefFromValue(image: ImageReadValue['image']): ImageAttachmentRef { + return { + attachmentId: AttachmentId(image.attachmentId), + mediaType: image.mediaType, + bytes: image.bytes, + width: image.width, + height: image.height, + ...image.name === undefined ? {} : { name: image.name }, + } +} + +/** + * Format an image read as the model-facing envelope beside its image block. + * @param displayPath - the backend-resolved path rendered in the envelope's `` element. + * @param image - the canonical image metadata to summarize. + * @returns the model-facing envelope; the image itself rides the adjacent image block. + */ +export function formatImageReadOutput(displayPath: string, image: ImageReadValue['image']): string { + return `${displayPath} +image + +${image.mediaType} image, ${image.width}x${image.height} px, ${image.bytes} bytes +` +} + +/** + * Project one canonical image read into its model-facing envelope and image. + * @param value - the canonical image-read outcome. + * @returns the two content blocks used by native and nested dispatches. + */ +function imageReadContent(value: ImageReadValue): ContentBlock[] { + return [ + { type: 'text', text: formatImageReadOutput(value.path, value.image) }, + { type: 'image', attachment: imageRefFromValue(value.image) }, + ] +} + +/** + * Register the `read_image` tool into the given context. The composing plugin + * owns the attachments gate: `src/index.ts` calls this inside + * `ctx.inject(['attachments'], …)` so the tool exists only while a durable + * store is mounted. Execution still re-checks `ctx.get('attachments')` for + * direct callers and gates on the calling route's declared image input. + * @param ctx - the registration scope; execution uses its `fs` service plus + * the optional `attachments`/`llm` services. + */ +export function applyReadImageTool(ctx: Context): void { + ctx.tools.register(defineTool({ + name: 'read_image', + description: 'Read a PNG/JPEG/WebP/GIF file and return the image itself. Requires the current model to accept image input.', + parameters: { + file_path: { type: 'string', required: true, description: 'Path to the image file, resolved by the filesystem backend.' }, + }, + output: { + schema: { + type: 'object', + additionalProperties: false, + properties: { + path: { type: 'string', required: true }, + image: { + type: 'object', + additionalProperties: false, + required: true, + properties: { + attachmentId: { type: 'string', required: true }, + mediaType: { type: 'string', enum: ['image/png', 'image/jpeg', 'image/webp', 'image/gif'], required: true }, + bytes: { type: 'integer', required: true }, + width: { type: 'integer', required: true }, + height: { type: 'integer', required: true }, + name: { type: 'string' }, + }, + }, + }, + }, + render: (_args, value) => imageReadContent(value), + }, + // Content-addressed attachment writes are idempotent, so concurrent reads + // of the same file cannot conflict. + isConcurrencySafe: () => true, + async execute(args, exec) { + if (args.file_path.trim().length === 0) throw new Error('file_path must be a non-empty string') + + // Every gate runs before any filesystem I/O so a refusal never leaks + // partial reads or attachment writes. + const mediaType = imageMediaTypeForPath(args.file_path) + if (mediaType === undefined) { + throw new Error(`cannot read "${args.file_path}": read_image only accepts PNG/JPEG/WebP/GIF paths`) + } + const attachments = ctx.get('attachments') + if (attachments === undefined) { + throw new Error(`cannot read "${args.file_path}" as an image: no attachment service is mounted`) + } + if (!attachments.imageLimits.mediaTypes.includes(mediaType)) { + throw new Error(`cannot read "${args.file_path}": ${mediaType} images are not accepted by this deployment`) + } + await assertImageCapableRoute(ctx, exec, args.file_path) + + const { target, info } = await resolveRegularReadTarget(ctx, exec, args.file_path) + + // The tool result is one message carrying one image, so the per-message + // aggregate bound applies beside the per-image bound. + const byteCap = Math.min(attachments.imageLimits.maxImageBytes, attachments.imageLimits.maxMessageImageBytes) + const data = await ctx.fs.readBytes(target, exec.signal, byteCap) + // Persist before returning: the image block must reference a durably + // committed object by the time the tool/result event is appended. + let ref: ImageAttachmentRef + try { + ref = await attachments.saveImage({ data, mediaType, name: basename(target.displayPath) }) + } catch (error: unknown) { + if (!(error instanceof AttachmentError) || error.code !== 'IMAGE_TYPE_MISMATCH') throw error + const extension = extname(target.displayPath).toLowerCase() + throw new Error( + `cannot read "${target.displayPath}": the ${extension} extension declares ${mediaType}, but the bytes use a different image format; rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats`, + { cause: error }, + ) + } + ctx.emit('fs/observed', target, { kind: 'present', version: info.version }, exec) + const value: ImageReadValue = { + path: target.displayPath, + image: { + attachmentId: ref.attachmentId, + mediaType: ref.mediaType, + bytes: ref.bytes, + width: ref.width, + height: ref.height, + ...ref.name === undefined ? {} : { name: ref.name }, + }, + } + if (exec.parent !== undefined) { + exec.deferContext(createUserMessage({ + content: imageReadContent(value), + source: { kind: 'plugin', plugin: 'tool-fs' }, + })) + } + return value + }, + // Pure display: a generic card in the read family with a follow-along + // location on the image file. + presentCall(args): GenericCallView { + return { + card: 'generic', + title: `Read image ${args.file_path}`, + kind: 'read', + locations: [{ path: args.file_path }], + } + }, + })) +} diff --git a/packages/fs/tool-fs/src/read-target.ts b/packages/fs/tool-fs/src/read-target.ts new file mode 100644 index 0000000000..98f4e05cdb --- /dev/null +++ b/packages/fs/tool-fs/src/read-target.ts @@ -0,0 +1,34 @@ +/** + * Shared path resolution and regular-file validation for model-facing read tools. + * @module @deepseek-ai/dsh-tool-fs/src/read-target + */ + +import type { Context } from '@deepseek-ai/cordis' +import { FsError } from '@deepseek-ai/dsh-fs' +import type { FsInfo, FsTarget } from '@deepseek-ai/dsh-fs' +import type { ToolExecution } from '@deepseek-ai/dsh-tools' +import { sessionResolveOptions } from './session-cwd.ts' + +/** + * Resolve a model-supplied path, observe absence, and require a regular file. + * @param ctx - the plugin context providing filesystem resolution and observation events. + * @param exec - the current tool execution, including session cwd and cancellation. + * @param requestedPath - the raw path supplied to the tool. + * @returns the resolved target and its single stat result. + */ +export async function resolveRegularReadTarget( + ctx: Context, + exec: ToolExecution, + requestedPath: string, +): Promise<{ target: FsTarget; info: FsInfo }> { + const target = await ctx.fs.resolve(requestedPath, sessionResolveOptions(exec, requestedPath)) + const info = await ctx.fs.stat(target, exec.signal) + if (info === undefined) { + ctx.emit('fs/observed', target, { kind: 'absent' }, exec) + throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + } + if (info.type !== 'file') { + throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + } + return { target, info } +} diff --git a/packages/fs/tool-fs/src/read.ts b/packages/fs/tool-fs/src/read.ts index dc4f07e8fe..cc9bd4e937 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -4,14 +4,13 @@ * @module @deepseek-ai/dsh-tool-fs/src/read */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, ReadResultView, ToolResult } from '@deepseek-ai/dsh-tools' -import { FsError } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-system-prompt' import { buildWindow, formatReadOutput, langFromPath, readMetaFromMeta } from './read-render.ts' -import { sessionResolveOptions } from './session-cwd.ts' +import { resolveRegularReadTarget } from './read-target.ts' /** Default and maximum number of lines returned by one `read` call (the `readLimit` config). */ export const READ_LIMIT = 2000 @@ -136,16 +135,9 @@ export function applyReadTool(ctx: Context, caps: ReadToolCaps): void { isConcurrencySafe: () => true, async execute(args, exec) { const input = parseReadArgs(args, caps.limit) - const target = await ctx.fs.resolve(input.filePath, sessionResolveOptions(exec, input.filePath)) - // One stat: absence observation OR type check + size routing + present version. // A concurrent write can only make a later guarded mutation fail stale and require reread. - const info = await ctx.fs.stat(target, exec.signal) - if (!info) { - ctx.emit('fs/observed', target, { kind: 'absent' }, exec) - throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') - } - if (info.type !== 'file') throw new FsError(`cannot read "${target.displayPath}": not a regular file`, 'FS_NOT_REGULAR_FILE') + const { target, info } = await resolveRegularReadTarget(ctx, exec, input.filePath) // Stream when the file is large OR size is unknown, so a size-less backend // never buffers an arbitrarily large file. diff --git a/packages/fs/tool-fs/src/sandbox.ts b/packages/fs/tool-fs/src/sandbox.ts index ca824ceea5..79a2381441 100644 --- a/packages/fs/tool-fs/src/sandbox.ts +++ b/packages/fs/tool-fs/src/sandbox.ts @@ -10,7 +10,7 @@ * @module @deepseek-ai/dsh-tool-fs/sandbox */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ToolExecution } from '@deepseek-ai/dsh-tools' import type { SandboxExecutionPolicy, SandboxMode } from '@deepseek-ai/dsh-sandbox' import { ESCALATION_TARGETS, approveEscalation, escalationHintMarker, sandboxDenialMarker, validateEscalationArgs } from '@deepseek-ai/dsh-sandbox' diff --git a/packages/fs/tool-fs/src/write.ts b/packages/fs/tool-fs/src/write.ts index ba96e32cd1..12f0ef2652 100644 --- a/packages/fs/tool-fs/src/write.ts +++ b/packages/fs/tool-fs/src/write.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-tool-fs/src/write */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { DiffCallView, DiffResultView, ToolResult } from '@deepseek-ai/dsh-tools' import type { FsWriteOutcome } from '@deepseek-ai/dsh-fs' diff --git a/packages/fs/tool-fs/tests/fs-tools.e2e.ts b/packages/fs/tool-fs/tests/fs-tools.e2e.ts index 290d7125a7..387fc2410c 100644 --- a/packages/fs/tool-fs/tests/fs-tools.e2e.ts +++ b/packages/fs/tool-fs/tests/fs-tools.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' import { fsHarness, waitForIdle } from './harness.ts' diff --git a/packages/fs/tool-fs/tests/harness.ts b/packages/fs/tool-fs/tests/harness.ts index 0d700e61b6..24a8b4d606 100644 --- a/packages/fs/tool-fs/tests/harness.ts +++ b/packages/fs/tool-fs/tests/harness.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/fs/tool-fs/tests/integration.spec.ts b/packages/fs/tool-fs/tests/integration.spec.ts index d2a63e0a38..c3845f76c2 100644 --- a/packages/fs/tool-fs/tests/integration.spec.ts +++ b/packages/fs/tool-fs/tests/integration.spec.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts new file mode 100644 index 0000000000..03f65d437d --- /dev/null +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -0,0 +1,499 @@ +/** + * The `read_image` tool over the REAL local filesystem and attachment store: + * extension routing, the strict image-modality gate (every refusal arm), + * durable commit + image-block rendering, attachment admission failures, and + * the regression that `read` keeps its text-only contract. + */ + +import { afterEach, beforeEach, describe, expect, it } from 'vitest' +import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import { CodeRuntime } from '@deepseek-ai/dsh-code-runtime' +import type { CodeRunRequest, CodeRunResult } from '@deepseek-ai/dsh-code-runtime' +import { CallId, LlmAdapter, LlmService } from '@deepseek-ai/dsh-llm' +import type { GenerateOptions, LlmModelInfo, LlmResolvedModelInfo, StreamChunk } from '@deepseek-ai/dsh-llm' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry, { RUN_CODE_NAME } from '@deepseek-ai/dsh-tools' +import type { Config as ToolConfig } from '@deepseek-ai/dsh-tools' +import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import * as FsPolicy from '@deepseek-ai/dsh-fs-policy' +import LocalAttachmentStore from '@deepseek-ai/dsh-attachment-local' +import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' +import * as ToolFs from '@deepseek-ai/dsh-tool-fs' +import { + applyReadImageTool, + formatImageReadOutput, + imageMediaTypeForPath, + imageRefFromValue, +} from '../src/read-image.ts' + +/** 1x1 red PNG (valid signature, IHDR, IDAT). */ +const PNG_1X1 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAIAAACQd1PeAAAADElEQVR4nGP4z8AAAAMBAQDJ/pLvAAAAAElFTkSuQmCC', 'base64') +/** 3x3 red PNG used to trip a tiny configured pixel limit. */ +const PNG_3X3 = Buffer.from('iVBORw0KGgoAAAANSUhEUgAAAAMAAAADCAIAAADZSiLoAAAAEElEQVR4nGP4z8AAQQxYWACPjgj4kWPEuQAAAABJRU5ErkJggg==', 'base64') + +const testToolSignal = new AbortController().signal + +/** Exact-route fake adapter; `stream` is unreachable in these tests. */ +class CatalogAdapter extends LlmAdapter { + constructor( + private readonly models: LlmModelInfo[], + private readonly resolvedModels: LlmModelInfo[] = models, + ) { + super() + } + + override listModels(_provider: string): Promise { + return Promise.resolve(this.models) + } + + override resolveModel(provider: string, model: string): Promise { + const resolved = this.resolvedModels.find(candidate => candidate.id === model) + return Promise.resolve({ + provider, + id: model, + name: resolved?.name ?? model, + ...resolved?.inputModalities === undefined ? {} : { inputModalities: [...resolved.inputModalities] }, + }) + } + + override stream(_options: GenerateOptions): AsyncIterable { + throw new Error('read_image tests never stream') + } +} + +/** In-process Code Mode seam fake that invokes the real registry bindings. */ +class FakeRuntime extends CodeRuntime { + readonly language = 'typescript' + readonly isolation = 'fake' + behavior: (request: CodeRunRequest) => Promise = () => Promise.resolve({ logs: [] }) + + run(request: CodeRunRequest): Promise { + return this.behavior(request) + } +} + +let dir: string +let home: string + +beforeEach(async () => { + dir = await mkdtemp(join(tmpdir(), 'dsh-read-image-')) + home = await mkdtemp(join(tmpdir(), 'dsh-read-image-home-')) +}) +afterEach(async () => { + await rm(dir, { recursive: true, force: true }) + await rm(home, { recursive: true, force: true }) +}) + +interface SetupOptions { + models?: LlmModelInfo[] + resolvedModels?: LlmModelInfo[] + attachments?: boolean + llm?: boolean + storeConfig?: { maxImageBytes?: number; maxImagePixels?: number; maxMessageImageBytes?: number } + toolMode?: ToolConfig['mode'] +} + +async function setup(options: SetupOptions = {}) { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry, { mode: options.toolMode ?? 'native' }) + if (options.toolMode === 'code' || options.toolMode === 'both') { + await ctx.plugin(FakeRuntime) + } + await ctx.plugin(LocalFileSystem, { cwd: dir }) + await ctx.plugin(FsPolicy) + if (options.attachments !== false) { + await ctx.plugin(LocalAttachmentStore, { dshHome: home, ...options.storeConfig }) + } + if (options.llm !== false) { + await ctx.plugin(LlmService) + ctx.llm.registerAdapter(['visual'], new CatalogAdapter(options.models ?? [ + { provider: 'visual', id: 'vision-model', name: 'Vision', inputModalities: ['text', 'image'] }, + { provider: 'visual', id: 'text-model', name: 'Text', inputModalities: ['text'] }, + { provider: 'visual', id: 'legacy-model', name: 'Legacy' }, + ], options.resolvedModels)) + } + await ctx.plugin(ToolFs) + return ctx +} + +/** A fake calling agent pinned to one routed provider/model. */ +function agentOn(model: string | undefined, provider = 'visual'): object { + return { + options: {}, + session: { + header: { cwd: dir }, + requestHeader: () => (model === undefined ? undefined : { config: { provider, model } }), + append: () => undefined, + }, + } +} + +let callCounter = 0 +function call(ctx: Context, name: string, args: unknown, agent?: object) { + return ctx.tools.execute({ + signal: testToolSignal, + callId: CallId(`img-call-${++callCounter}`), + name, + arguments: args, + ...agent ? { agent: agent as never } : {}, + }) +} + +function readImage(ctx: Context, args: unknown, agent?: object) { + return call(ctx, 'read_image', args, agent) +} + +function text(result: { content: { type: string; text?: string }[] }): string { + return result.content.filter(b => b.type === 'text').map(b => b.text).join('') +} + +describe('imageMediaTypeForPath', () => { + it('maps the four extensions case-insensitively and rejects everything else', () => { + expect(imageMediaTypeForPath('a.png')).toBe('image/png') + expect(imageMediaTypeForPath('a.JPG')).toBe('image/jpeg') + expect(imageMediaTypeForPath('b.jpeg')).toBe('image/jpeg') + expect(imageMediaTypeForPath('c.webp')).toBe('image/webp') + expect(imageMediaTypeForPath('d.Gif')).toBe('image/gif') + expect(imageMediaTypeForPath('note.txt')).toBeUndefined() + expect(imageMediaTypeForPath('png')).toBeUndefined() + }) +}) + +describe('imageRefFromValue', () => { + it('re-brands with and without the optional display name', () => { + const base = { attachmentId: 'sha256:00', mediaType: 'image/png' as const, bytes: 1, width: 1, height: 1 } + expect(imageRefFromValue(base)).toEqual(base) + expect(imageRefFromValue({ ...base, name: 'a.png' })).toEqual({ ...base, name: 'a.png' }) + }) +}) + +describe('read_image happy path', () => { + it('commits the bytes durably and renders the envelope beside an image block', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup() + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + + expect(result.isError).toBe(false) + expect(result.content).toHaveLength(2) + const image = result.content[1] as { type: string; attachment: ImageAttachmentRef } + expect(image.type).toBe('image') + expect(image.attachment.mediaType).toBe('image/png') + expect(image.attachment.width).toBe(1) + expect(image.attachment.height).toBe(1) + expect(image.attachment.bytes).toBe(PNG_1X1.length) + expect(image.attachment.name).toBe('red.png') + expect(image.attachment.attachmentId).toMatch(/^sha256:[0-9a-f]{64}$/) + expect(text(result)).toBe(formatImageReadOutput(join(dir, 'red.png'), { + attachmentId: image.attachment.attachmentId, + mediaType: 'image/png', + bytes: PNG_1X1.length, + width: 1, + height: 1, + })) + + // The committed object must read back verbatim through the store. + const attachments = ctx.get('attachments') + if (attachments === undefined) throw new Error('expected the attachment service') + const stored = await attachments.readImage(image.attachment) + expect(Buffer.from(stored.data)).toEqual(PNG_1X1) + }) + + it('emits fs/observed for the read image', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup() + const observed: string[] = [] + ctx.on('fs/observed', target => void observed.push(target.displayPath)) + await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(observed).toEqual([join(dir, 'red.png')]) + }) + + it('falls back to agent options when no request header exists yet', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup() + const agent = { + options: { provider: 'visual', model: 'vision-model' }, + session: { header: { cwd: dir }, requestHeader: () => undefined }, + } + const result = await readImage(ctx, { file_path: 'red.png' }, agent) + expect(result.isError).toBe(false) + }) + + it('forwards a nested Code Mode image through the outer run_code context', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ toolMode: 'code' }) + const runtime = ctx.codeRuntime as FakeRuntime + runtime.behavior = async (request) => { + const value = await request.bindings[0]!.functions.read_image!({ file_path: 'red.png' }) + return { logs: [], value } + } + + const result = await call(ctx, RUN_CODE_NAME, { + code: 'return await tools.read_image({ file_path: "red.png" })', + description: 'Read the image through Code Mode', + }, agentOn('vision-model')) + + expect(result.isError).toBe(false) + expect(result.content.every(block => block.type === 'text')).toBe(true) + expect(result.additionalContexts).toHaveLength(1) + const forwarded = result.additionalContexts?.[0]?.content + expect(forwarded).toHaveLength(2) + expect(forwarded?.[0]?.type).toBe('text') + expect(forwarded?.[0]?.type === 'text' ? forwarded[0].text : '').toContain('image') + expect(forwarded?.[1]).toMatchObject({ + type: 'image', + attachment: { mediaType: 'image/png', width: 1, height: 1 }, + }) + }) +}) + +describe('strict image-modality gate', () => { + it('accepts an exact visual route even when the advisory model catalog omits it', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ + models: [], + resolvedModels: [ + { provider: 'visual', id: 'hidden-vision', name: 'Hidden Vision', inputModalities: ['text', 'image'] }, + ], + }) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('hidden-vision')) + expect(result.isError).toBe(false) + }) + + it.each([ + ['a text-only model', 'text-model'], + ['a model without declared modalities', 'legacy-model'], + ['a model absent from the catalog', 'unknown-model'], + ])('refuses on %s', async (_label, model) => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup() + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn(model)) + expect(result.isError).toBe(true) + expect(text(result)).toContain('does not declare image input') + }) + + it('refuses when the route cannot be resolved (no agent, or no header and no options)', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup() + const noAgent = await readImage(ctx, { file_path: 'red.png' }) + expect(noAgent.isError).toBe(true) + expect(text(noAgent)).toContain('route could not be resolved') + + const noRoute = await readImage(ctx, { file_path: 'red.png' }, agentOn(undefined)) + expect(noRoute.isError).toBe(true) + expect(text(noRoute)).toContain('route could not be resolved') + }) + + it('refuses when no llm service is mounted', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ llm: false }) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('route could not be resolved') + }) +}) + +describe('argument and service preconditions', () => { + it('rejects an empty path and a non-image extension', async () => { + const ctx = await setup() + const empty = await readImage(ctx, { file_path: ' ' }, agentOn('vision-model')) + expect(empty.isError).toBe(true) + expect(text(empty)).toContain('non-empty') + + const nonImage = await readImage(ctx, { file_path: 'notes.txt' }, agentOn('vision-model')) + expect(nonImage.isError).toBe(true) + expect(text(nonImage)).toContain('only accepts PNG/JPEG/WebP/GIF paths') + }) + + it('refuses when no attachment service is mounted', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ attachments: false }) + expect(ctx.tools.get('read_image')).toBeUndefined() + expect(ctx.tools.schemas().map(schema => schema.name)).not.toContain('read_image') + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('unknown tool "read_image"') + }) + + it('defensively refuses execution without an attachment service', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ attachments: false }) + applyReadImageTool(ctx) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('no attachment service is mounted') + }) + + it('refuses a media type the deployment does not accept', async () => { + /** Store whose deployment accepts JPEG only. */ + class JpegOnlyStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 100, + mediaTypes: Object.freeze(['image/jpeg'] as const), + }) + + validateImage(_input: SaveImageAttachment): Promise { + throw new Error('unreachable: admission refuses before validation') + } + + saveImage(_input: SaveImageAttachment): Promise { + throw new Error('unreachable: admission refuses before save') + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('unreachable in this test') + } + } + const ctx = await setup({ attachments: false }) + await ctx.plugin(JpegOnlyStore) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('image/png images are not accepted by this deployment') + }) +}) + +describe('image admission failures', () => { + it('explains how to repair a declared/actual media-type mismatch', async () => { + await writeFile(join(dir, 'wrong.jpg'), PNG_1X1) + const ctx = await setup() + const result = await readImage(ctx, { file_path: 'wrong.jpg' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('the .jpg extension declares image/jpeg') + expect(text(result)).toContain('rename the file to match its actual format if it is PNG/JPEG/WebP/GIF, or convert it to one of those formats') + }) + + it('fails with FS_TOO_LARGE before reading a file past maxImageBytes', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ storeConfig: { maxImageBytes: PNG_1X1.length - 1 } }) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('exceeds') + }) + + it('honors the tighter per-message aggregate byte bound', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ storeConfig: { maxMessageImageBytes: PNG_1X1.length - 1 } }) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + expect(text(result)).toContain('exceeds') + }) + + it('surfaces the pixel limit from the attachment admission', async () => { + await writeFile(join(dir, 'big.png'), PNG_3X3) + const ctx = await setup({ storeConfig: { maxImagePixels: 4 } }) + const result = await readImage(ctx, { file_path: 'big.png' }, agentOn('vision-model')) + expect(result.isError).toBe(true) + }) + + it('reports a missing image file and a directory target through the fs vocabulary', async () => { + await mkdir(join(dir, 'folder.png')) + const ctx = await setup() + const observed: { path: string; kind: string }[] = [] + ctx.on('fs/observed', (target, observation) => void observed.push({ path: target.displayPath, kind: observation.kind })) + const missing = await readImage(ctx, { file_path: 'absent.png' }, agentOn('vision-model')) + expect(missing.isError).toBe(true) + expect(text(missing)).toContain('not found') + expect(observed).toEqual([{ path: join(dir, 'absent.png'), kind: 'absent' }]) + + const directory = await readImage(ctx, { file_path: 'folder.png' }, agentOn('vision-model')) + expect(directory.isError).toBe(true) + expect(text(directory)).toContain('not a regular file') + }) + + it('omits the display name when the store returns a reference without one', async () => { + /** Store echoing a fixed nameless reference; deployments may strip names entirely. */ + class NamelessStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 1024, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1024, + maxImagePixels: 100, + mediaTypes: Object.freeze(['image/png'] as const), + }) + + validateImage(_input: SaveImageAttachment): Promise { + return Promise.resolve() + } + + async saveImage(input: SaveImageAttachment): Promise { + return { attachmentId: AttachmentId('sha256:feed'), mediaType: input.mediaType, bytes: input.data.length, width: 1, height: 1 } + } + + readImage(_ref: ImageAttachmentRef): Promise { + throw new Error('unreachable in this test') + } + } + await writeFile(join(dir, 'red.png'), PNG_1X1) + const ctx = await setup({ attachments: false }) + await ctx.plugin(NamelessStore) + const result = await readImage(ctx, { file_path: 'red.png' }, agentOn('vision-model')) + expect(result.isError).toBe(false) + const image = result.content[1] as { attachment: ImageAttachmentRef } + expect(image.attachment.name).toBeUndefined() + }) +}) + +describe('registration surface', () => { + it('withdraws read_image when the tool-fs fiber or the attachment store is disposed (HMR safety)', async () => { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry, { mode: 'native' }) + await ctx.plugin(LocalFileSystem, { cwd: dir }) + await ctx.plugin(FsPolicy) + const attachmentsFiber = await ctx.plugin(LocalAttachmentStore, { dshHome: home }) + const toolFsFiber = await ctx.plugin(ToolFs) + const names = () => ctx.tools.schemas().map(schema => schema.name).sort() + expect(names()).toEqual(['edit', 'read', 'read_image', 'write']) + + // Disposing only the attachment store tears down the scoped inject fiber: + // read_image withdraws while the unconditional tools stay registered. + await attachmentsFiber.dispose() + expect(names()).toEqual(['edit', 'read', 'write']) + + // Remounting the store restores the conditional registration. + const remounted = await ctx.plugin(LocalAttachmentStore, { dshHome: home }) + expect(names()).toEqual(['edit', 'read', 'read_image', 'write']) + + // Disposing the whole plugin withdraws every tool, read_image included. + await toolFsFiber.dispose() + expect(names()).toEqual([]) + await remounted.dispose() + }) + + it('declares read_image parallel-safe and presents a read-family card', async () => { + const ctx = await setup() + expect(ctx.tools.executionMode({ + signal: testToolSignal, callId: CallId('img-parallel'), name: 'read_image', arguments: { file_path: 'a.png' }, + })).toEqual({ kind: 'parallel' }) + expect(ctx.tools.get('read_image')?.presentCall?.({ file_path: 'shot.png' })).toEqual({ + card: 'generic', + title: 'Read image shot.png', + kind: 'read', + locations: [{ path: 'shot.png' }], + }) + }) +}) + +describe('read keeps its text-only contract', () => { + it('still refuses a PNG as a binary file and line-numbers text', async () => { + await writeFile(join(dir, 'red.png'), PNG_1X1) + await writeFile(join(dir, 'note.txt'), 'hello\nworld') + const ctx = await setup() + + const png = await call(ctx, 'read', { file_path: 'red.png' }, agentOn('vision-model')) + expect(png.isError).toBe(true) + expect(text(png)).toContain('binary file') + + const txt = await call(ctx, 'read', { file_path: 'note.txt' }, agentOn('text-model')) + expect(txt.isError).toBe(false) + expect(text(txt)).toContain('1: hello') + expect(text(txt)).toContain('file') + }) +}) diff --git a/packages/fs/tool-fs/tests/tools.spec.ts b/packages/fs/tool-fs/tests/tools.spec.ts index b217260972..b15bf4ef8f 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -4,7 +4,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' @@ -71,6 +71,13 @@ class FakeFs extends FileSystem { const content = this.files.get(target.targetKey) ?? '' return (async function* () { yield content })() } + override async readBytes(target: FsTarget, _signal: AbortSignal | undefined, maxBytes: number): Promise { + const bytes = new TextEncoder().encode(this.files.get(target.targetKey) ?? '') + if (bytes.length > maxBytes) { + throw new FsError(`too large: ${target.displayPath}`, 'FS_TOO_LARGE') + } + return bytes + } override async listDir(_target: FsTarget): Promise { return [] } diff --git a/packages/fs/tool-fs/tsconfig.json b/packages/fs/tool-fs/tsconfig.json index a3f128143c..62e7230554 100644 --- a/packages/fs/tool-fs/tsconfig.json +++ b/packages/fs/tool-fs/tsconfig.json @@ -41,6 +41,9 @@ }, { "path": "../../interaction/user-approval" + }, + { + "path": "../../attachment/attachment" } ] } diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index b9404e20d6..5b20d4afc4 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-str-replace-editor", "description": "Model-facing view, create, literal replace, and line insert tool over the Harness filesystem service", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/fs/tool-str-replace-editor" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -24,15 +31,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -47,6 +54,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/tool-str-replace-editor/src/index.ts b/packages/fs/tool-str-replace-editor/src/index.ts index 4b93c6d82c..c8afd16064 100644 --- a/packages/fs/tool-str-replace-editor/src/index.ts +++ b/packages/fs/tool-str-replace-editor/src/index.ts @@ -4,8 +4,8 @@ */ import { isAbsolute } from 'node:path' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { FsError } from '@deepseek-ai/dsh-fs' import type { FsInfo, FsTarget, FsWriteIntent } from '@deepseek-ai/dsh-fs' import { sandboxDenialMarker } from '@deepseek-ai/dsh-sandbox' diff --git a/packages/fs/tool-str-replace-editor/src/invariant.ts b/packages/fs/tool-str-replace-editor/src/invariant.ts index 99547c02ee..c2b6d8f149 100644 --- a/packages/fs/tool-str-replace-editor/src/invariant.ts +++ b/packages/fs/tool-str-replace-editor/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-str-replace-editor' diff --git a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts index db0fe8dc35..987f78b69b 100644 --- a/packages/fs/tool-str-replace-editor/tests/tools.spec.ts +++ b/packages/fs/tool-str-replace-editor/tests/tools.spec.ts @@ -2,7 +2,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { FsVersion } from '@deepseek-ai/dsh-fs' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 7313daa4af..b87f385599 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/goal/command-goal" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,19 +32,19 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-commands": "^0.0.1", - "@deepseek-ai/dsh-goal": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-commands": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/goal/command-goal/src/index.ts b/packages/goal/command-goal/src/index.ts index 93ed7923b8..38d18e2529 100644 --- a/packages/goal/command-goal/src/index.ts +++ b/packages/goal/command-goal/src/index.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-command-goal */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { CommandInvocation, CommandResult } from '@deepseek-ai/dsh-commands' import { GoalError } from '@deepseek-ai/dsh-goal' import type { GoalPhase, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' diff --git a/packages/goal/command-goal/src/invariant.ts b/packages/goal/command-goal/src/invariant.ts index 795294b4e8..673d6a27ef 100644 --- a/packages/goal/command-goal/src/invariant.ts +++ b/packages/goal/command-goal/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-command-goal' diff --git a/packages/goal/command-goal/tests/command-goal.spec.ts b/packages/goal/command-goal/tests/command-goal.spec.ts index 03bf965fd6..9479859e7d 100644 --- a/packages/goal/command-goal/tests/command-goal.spec.ts +++ b/packages/goal/command-goal/tests/command-goal.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import CommandService from '@deepseek-ai/dsh-commands' diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json index 86b58f3cda..fa42ef63d3 100644 --- a/packages/goal/goal-session/package.json +++ b/packages/goal/goal-session/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-goal-session", "description": "Race-fenced same-session goal-round driver", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/goal/goal-session" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,12 +32,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-goal": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -42,6 +49,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 64c90e0c35..1d78dc5a50 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -4,8 +4,8 @@ */ import { isDeepStrictEqual } from 'node:util' -import { FiberState } from 'cordis' -import type { Context } from 'cordis' +import { FiberState } from '@deepseek-ai/cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import type { GoalMessageSource, GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/goal/goal-session/src/invariant.ts b/packages/goal/goal-session/src/invariant.ts index 53cdd9b20b..121343dfff 100644 --- a/packages/goal/goal-session/src/invariant.ts +++ b/packages/goal/goal-session/src/invariant.ts @@ -1,7 +1,7 @@ /** Package-owned goal-round prompt invariants. @module @deepseek-ai/dsh-goal-session/invariant */ import { isDeepStrictEqual } from 'node:util' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { foldGoal, type FoldedGoal, type GoalMessageSource, type GoalView } from '@deepseek-ai/dsh-goal' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 2d14abf158..1231e659c2 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { agentEvents } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/packages/goal/goal-session/tests/invariant.spec.ts b/packages/goal/goal-session/tests/invariant.spec.ts index 6baaa724da..76830591e3 100644 --- a/packages/goal/goal-session/tests/invariant.spec.ts +++ b/packages/goal/goal-session/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { GoalId, type GoalSnapshotChangeMeta, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index fccf7de3be..a2f90fced3 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/goal/goal" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -48,18 +55,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-session-projection": "^0.0.1", - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-type-meta": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-type-meta": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.17.2", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { @@ -72,6 +79,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/goal/goal/src/domain.ts b/packages/goal/goal/src/domain.ts index 3dc5672f0b..63f1573dc7 100644 --- a/packages/goal/goal/src/domain.ts +++ b/packages/goal/goal/src/domain.ts @@ -101,7 +101,7 @@ export type GoalErrorCode = | 'GOAL_INVALID_EDIT' | 'GOAL_INVALID_TRANSITION' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { /** * Goal mutation accepted by one live agent. The matching `goal/change` diff --git a/packages/goal/goal/src/index.ts b/packages/goal/goal/src/index.ts index 6667463d86..c4f5d70ee8 100644 --- a/packages/goal/goal/src/index.ts +++ b/packages/goal/goal/src/index.ts @@ -5,8 +5,8 @@ */ import { randomUUID } from 'node:crypto' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { ZodType } from 'zod' import { agentEvents } from '@deepseek-ai/dsh-agent' @@ -56,7 +56,7 @@ export type * from './domain.ts' export { GOAL_CHANGE_VERSION, GoalError, GoalId } from './runtime.ts' export { decodeGoalChange, foldGoal, goalChangeRef } from './fold.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { goals: GoalService } diff --git a/packages/goal/goal/src/invariant.ts b/packages/goal/goal/src/invariant.ts index 42c83c65f0..31ac8e2f67 100644 --- a/packages/goal/goal/src/invariant.ts +++ b/packages/goal/goal/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned durable goal-stream invariants. @module @deepseek-ai/dsh-goal/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { applyGoalEvent, emptyGoalFoldState } from './fold.ts' diff --git a/packages/goal/goal/tests/goal.spec.ts b/packages/goal/goal/tests/goal.spec.ts index 0c0a8e8373..e94e0754a0 100644 --- a/packages/goal/goal/tests/goal.spec.ts +++ b/packages/goal/goal/tests/goal.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' diff --git a/packages/goal/goal/tests/invariant.spec.ts b/packages/goal/goal/tests/invariant.spec.ts index b342036f83..79d7f10704 100644 --- a/packages/goal/goal/tests/invariant.spec.ts +++ b/packages/goal/goal/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { GoalId, type GoalSnapshotChangeMeta, diff --git a/packages/goal/goal/tests/projection.spec.ts b/packages/goal/goal/tests/projection.spec.ts index 7dcab0dc40..6282a6f739 100644 --- a/packages/goal/goal/tests/projection.spec.ts +++ b/packages/goal/goal/tests/projection.spec.ts @@ -9,7 +9,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index c7c87e44db..e83648ab97 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/goal/tool-goal" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,20 +32,6 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-goal": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "dependencies": { - "schemastery": "^3.18.0" - }, - "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -46,6 +39,20 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-goal": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/goal/tool-goal/src/authority.ts b/packages/goal/tool-goal/src/authority.ts index ad0fe9affd..1fdfbc740a 100644 --- a/packages/goal/tool-goal/src/authority.ts +++ b/packages/goal/tool-goal/src/authority.ts @@ -1,6 +1,6 @@ /** Execution-time authority checks for the model-facing goal tools. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { GoalView } from '@deepseek-ai/dsh-goal' import { HarnessError } from '@deepseek-ai/dsh-llm' diff --git a/packages/goal/tool-goal/src/index.ts b/packages/goal/tool-goal/src/index.ts index d22ff26dc2..903190de4d 100644 --- a/packages/goal/tool-goal/src/index.ts +++ b/packages/goal/tool-goal/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-tool-goal */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { GoalId } from '@deepseek-ai/dsh-goal' import type { GoalRef, GoalView } from '@deepseek-ai/dsh-goal' import { boundContextSummary, createUserMessage, HarnessError } from '@deepseek-ai/dsh-llm' diff --git a/packages/goal/tool-goal/src/invariant.ts b/packages/goal/tool-goal/src/invariant.ts index d3ea60f049..28b9637772 100644 --- a/packages/goal/tool-goal/src/invariant.ts +++ b/packages/goal/tool-goal/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-goal' diff --git a/packages/goal/tool-goal/tests/tool-goal.spec.ts b/packages/goal/tool-goal/tests/tool-goal.spec.ts index d8bc950019..71469bc5c6 100644 --- a/packages/goal/tool-goal/tests/tool-goal.spec.ts +++ b/packages/goal/tool-goal/tests/tool-goal.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry, { agentEvents, Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentStatus } from '@deepseek-ai/dsh-agent' import GoalService, { GoalId } from '@deepseek-ai/dsh-goal' diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index 9978865a5d..27e7cc916e 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-repeat-tool-guard", "description": "Repeat-tool-call guard plugin: advisory reminders when an agent loops on identical tool calls", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/guard/repeat-tool-guard" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,13 +32,13 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -41,6 +48,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/guard/repeat-tool-guard/src/index.ts b/packages/guard/repeat-tool-guard/src/index.ts index 464dd318da..efc3914592 100644 --- a/packages/guard/repeat-tool-guard/src/index.ts +++ b/packages/guard/repeat-tool-guard/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-repeat-tool-guard */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { MessageSource } from '@deepseek-ai/dsh-llm' diff --git a/packages/guard/repeat-tool-guard/src/invariant.ts b/packages/guard/repeat-tool-guard/src/invariant.ts index 5d8544b9aa..29363695a2 100644 --- a/packages/guard/repeat-tool-guard/src/invariant.ts +++ b/packages/guard/repeat-tool-guard/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-repeat-tool-guard' diff --git a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts index 20cd9a0104..a8eda4cf4c 100644 --- a/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts +++ b/packages/guard/repeat-tool-guard/tests/repeat-tool-guard.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index b77bd23716..4800c0f472 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-timeout-policy", "description": "Tool-call timeout policy: a tools/execute wrapper that arms a per-tool deadline on exec.signal and returns TOOL_TIMEOUT when it wins", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/guard/timeout-policy" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/guard/timeout-policy/src/index.ts b/packages/guard/timeout-policy/src/index.ts index 1ff88b46a9..11c8f986d7 100644 --- a/packages/guard/timeout-policy/src/index.ts +++ b/packages/guard/timeout-policy/src/index.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-timeout-policy */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' diff --git a/packages/guard/timeout-policy/src/invariant.ts b/packages/guard/timeout-policy/src/invariant.ts index ddc3b3966e..ef2a25e6ae 100644 --- a/packages/guard/timeout-policy/src/invariant.ts +++ b/packages/guard/timeout-policy/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-timeout-policy' diff --git a/packages/guard/timeout-policy/tests/timeout-policy.spec.ts b/packages/guard/timeout-policy/tests/timeout-policy.spec.ts index c2d55d044c..cd8085785b 100644 --- a/packages/guard/timeout-policy/tests/timeout-policy.spec.ts +++ b/packages/guard/timeout-policy/tests/timeout-policy.spec.ts @@ -7,8 +7,8 @@ */ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { CallId, HarnessError } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { defineContentToolFixture, TOOL_ABORTED, type ToolExecutionInput, type PostToolDecision } from '@deepseek-ai/dsh-tools' diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 0e35d83513..ad92d9dd78 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-hook-protocol", "description": "Shared Claude Code / Codex hook wire protocol: matcher engine, stdin/exit-code/stdout codec, multi-hook merge, and hook/* session events", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/hooks/hook-protocol" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/hooks/hook-protocol/src/invariant.ts b/packages/hooks/hook-protocol/src/invariant.ts index 8a7c7338c3..d4a36dc9f1 100644 --- a/packages/hooks/hook-protocol/src/invariant.ts +++ b/packages/hooks/hook-protocol/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned hook invocation/result stream invariants. @module @deepseek-ai/dsh-hook-protocol/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type {} from './types.ts' diff --git a/packages/hooks/hook-protocol/tests/invariant.spec.ts b/packages/hooks/hook-protocol/tests/invariant.spec.ts index 94e5b57847..9ec212e5de 100644 --- a/packages/hooks/hook-protocol/tests/invariant.spec.ts +++ b/packages/hooks/hook-protocol/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import * as HookInvariant from '@deepseek-ai/dsh-hook-protocol/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index cce1d3edb9..9ed1ed7ceb 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-hooks-claude", "description": "Bridge plugin: run a Claude Code hooks.json / settings hook config on the DeepSeek Harness interception seams", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/hooks/hooks-claude" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,18 +32,18 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-hook-protocol": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-hook-protocol": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -53,6 +60,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/hooks/hooks-claude/src/index.ts b/packages/hooks/hooks-claude/src/index.ts index cb86d86c92..ae28cf00a0 100644 --- a/packages/hooks/hooks-claude/src/index.ts +++ b/packages/hooks/hooks-claude/src/index.ts @@ -10,8 +10,8 @@ */ import { readFileSync } from 'node:fs' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' diff --git a/packages/hooks/hooks-claude/src/invariant.ts b/packages/hooks/hooks-claude/src/invariant.ts index d7908419d4..cdf3fb3ec3 100644 --- a/packages/hooks/hooks-claude/src/invariant.ts +++ b/packages/hooks/hooks-claude/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-claude' diff --git a/packages/hooks/hooks-claude/tests/bridge.spec.ts b/packages/hooks/hooks-claude/tests/bridge.spec.ts index 244abae423..86b2692493 100644 --- a/packages/hooks/hooks-claude/tests/bridge.spec.ts +++ b/packages/hooks/hooks-claude/tests/bridge.spec.ts @@ -3,8 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context, type Fiber } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context, type Fiber } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/hooks/hooks-claude/tests/coverage-cases.ts b/packages/hooks/hooks-claude/tests/coverage-cases.ts index c82f0320f3..9411248d22 100644 --- a/packages/hooks/hooks-claude/tests/coverage-cases.ts +++ b/packages/hooks/hooks-claude/tests/coverage-cases.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 078b58fb08..a2bd73fba1 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-hooks-codex", "description": "Bridge plugin: run a Codex hooks.json hook config on the DeepSeek Harness interception seams", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/hooks/hooks-codex" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-hook-protocol": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-hook-protocol": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -51,6 +58,6 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/hooks/hooks-codex/src/index.ts b/packages/hooks/hooks-codex/src/index.ts index d31d9f8c48..3b7d0d7a40 100644 --- a/packages/hooks/hooks-codex/src/index.ts +++ b/packages/hooks/hooks-codex/src/index.ts @@ -13,8 +13,8 @@ // point; a cross-package facade for imports alone would add indirection. /* jscpd:ignore-start */ import { readFileSync } from 'node:fs' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, MessageSource } from '@deepseek-ai/dsh-llm' diff --git a/packages/hooks/hooks-codex/src/invariant.ts b/packages/hooks/hooks-codex/src/invariant.ts index c3eacb7ab0..5d19efb7b0 100644 --- a/packages/hooks/hooks-codex/src/invariant.ts +++ b/packages/hooks/hooks-codex/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-hooks-codex' diff --git a/packages/hooks/hooks-codex/tests/bridge.spec.ts b/packages/hooks/hooks-codex/tests/bridge.spec.ts index 0878397edd..cdfbe2c594 100644 --- a/packages/hooks/hooks-codex/tests/bridge.spec.ts +++ b/packages/hooks/hooks-codex/tests/bridge.spec.ts @@ -3,8 +3,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { chmodSync, existsSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/hooks/hooks-codex/tests/coverage-cases.ts b/packages/hooks/hooks-codex/tests/coverage-cases.ts index d92ea597bd..8690af565e 100644 --- a/packages/hooks/hooks-codex/tests/coverage-cases.ts +++ b/packages/hooks/hooks-codex/tests/coverage-cases.ts @@ -3,7 +3,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync, writeFileSync, chmodSync, existsSync, readFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' diff --git a/packages/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index e4ee0922d6..72568f241d 100644 --- a/packages/host/apiproxy/README.i18n.yaml +++ b/packages/host/apiproxy/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/host/apiproxy/README.md -README.md: 03726c6671ec711704870d23722d83c72d9c4d35 -README.zh.md: f001866af2015671ed6429b392e3f880000e6c38 +README.md: 5fe19af8069766c56f8926ccef88dc1d9fb3c950 +README.zh.md: bdb26a63832c1461b4e56798e64c1253a916118d diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index 03726c6671..5fe19af806 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -28,6 +28,8 @@ Question responses are validated against their pending request before the first `session.history`'s tail page (`beforeSeq` absent) additionally carries an optional `projections` block — the watermark snapshot of every unit registered on `ctx.sessionProjections` (`@deepseek-ai/dsh-session-projection`), with `asOfSeq` = the last event seq the values reflect (`-1` on an empty log). The gateway also subscribes to the registry's change feed and mints a `session/projection` mux frame per changed unit (`{sessionId, key, value, seq}` — live push state, never logged; clients hold one generic per-session value store under higher-seq-wins). The carrier holds zero domain knowledge (each value passed its unit's own schema inside the registry; the wire schemas keep `values`/`value` wide); loadOlder pages never carry the block, and a composition without the registry serves histories without either surface. +Session-log export is a host-only download surface, not an RPC: `GET /api/session.export?sessionId=…&includeDescendants=true` streams a ZIP whose files are each session's stored artifact text verbatim (the persistence backend's `readRaw` — exact durable bytes decoded from the physical encoding, never a reconstruction from parsed events), root under its original base name plus each subagent descendant under `subagents//`, and every image any included log references under `media/.` (read and verified from the attachment store; a shared image appears once). Compression runs on the host with fflate's streaming Zip API, so the response is chunked as it is produced and the host never holds the whole archive in one buffer, and production yields whenever the response queue fills, so a slow consumer bounds the accumulation (fflate's callback is synchronous — the drain point is the only backpressure). It requires the persistence, session-query, and attachment services: a deployment without any answers 500, a missing root session 404, and a descendant without a stored artifact or a referenced image that cannot be read fails the stream (fail-loud, never silent under-export). The carrier mounts the endpoint; `ApiProxy.downloads.sessionLog` implements it. + Session titles ride the generic projection pair like every other domain — the history-tail `projections` block plus `session/projection` frames under the `title` key. Titles do not join `session.list`; cold sessions remain metadata-only there until opening or resuming attaches their logs. `session.rename` accepts an explicit user title (resuming a cold session first), delegating to `ctx.sessionTitle.rename` — the accepted `session/title` event pins the title against automatic regeneration — and returns the normalized title plus its event seq so a client settles its `title` projection cell ahead of the push frame; a title that normalizes to empty returns `title-invalid`. `session.fork` maps an optional event anchor to the first `turn/end` at or after it, letting a message action include that message's whole turn. An omitted or past-end anchor selects the last completed turn; an in-log anchor whose turn remains open returns `fork-unavailable` rather than clipping backward. The published child inherits the source's seeded history, cwd, latest logged `ModelSelection`, and lineage before joining the source Workspace. If Workspace attachment fails, `workspace-attach-failed` carries the already-published child id so clients can reconcile it. The [SessionStore fork decision](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md) records why the anchor maps to that `turn/end`. @@ -36,6 +38,8 @@ Session model selection is a session-domain contract. `session.models` returns t Pending queued input is a live control-plane contract, not conversation history. The gateway derives the complete `next-turn` queue from durable `agent/inbox/spliced` mutations and broadcasts authoritative `session/queue` snapshots after each change and on reconnect; pending `next-step` steering stays outside this Web projection. Within `next-step`, user-origin messages carry the `steering` placement while injected context (approval notices, task completion, attached snapshots) carries `context` and is not surfaced until claimed. The message-local `agent/inbox/inserted`, `claimed`, and `discarded` notifications remain available to lifecycle observers but do not build the queue view. `session.updateQueue` addresses one `MessageId`; edit and remove mutate the attached Agent through `Inbox.splice()`. A claim's pure deletion splice wins races before pre-step admission, so a later operation returns `queue-item-not-found`. `session.cancel` aborts only the active turn and preserves pending inbox work; after cancellation reaches quiescence and the closing turn flushes, AgentLoop claims the next waking message in FIFO order, and the browser never resends or promotes it. Queue operations never resume a cold session, and the client never infers retirement from turn or status events. +Background tasks ride the same live-push posture. When `ctx.tasks` is composed, the gateway subscribes to its change feed and broadcasts a whole `session/tasks` snapshot after every registry commit that alters what a session can see — registration, the stopping transition, settlement, and owner-disposal removal — plus a subscription baseline for each session that already has tasks (an absent baseline is the empty set; a change that empties a set still sends `[]`). A change carrying an owner reads through that exact `Agent`, so a push stays correct while its scope tears down; the baseline reads `ctx.agents.get(sessionId)`, which yields only unowned tasks for a session with no live Agent and never resumes a cold one. An unowned change fans out to every subscribed session, because unowned tasks are visible to every caller. The wire `TaskView` drops `ownerSession`, `reported`, and `outputLimitBytes`: the frame's own `sessionId` carries the first, and the other two are internal notice and model-presentation policy. A composition without the registry emits no such frames. + Workspace and Session lists are separate reconnect baselines. `workspace.create({ path })` adopts an existing canonical directory and permits basename-derived titles to repeat. `workspace.delete` removes only the Workspace registration, `session.create` accepts an optional preallocated Session id, and `host/workspace-changed`, `host/workspace-removed`, plus `host/session-added` carry committed increments in either arrival order. `workspace.archiveSession` adds one session to the registry-global archive set and answers the full updated set; `workspace.list` carries that set as the reconnect baseline and `host/archived-sessions-changed` pushes the full snapshot after every durable change. Archiving hides the session from grouping surfaces without touching its log or its workspace account; a session neither live nor persisted fails with `session-not-found`. Registration deletion preserves the directory and session logs; its Sessions remain in `session.list` and become Ungrouped. `SessionSummary.blank` and the `host/session-added` frame carry the derived zero-events bit: clients hide blank sessions and reuse them per workspace, flip blank on the first `host/session-status(running:true)`, and treat `session.list` as the reconnect authority; cold summaries are never blank because lazy persistence keeps never-appended sessions out of `list()`. `session.search` is a bounded content-search projection over the sessions visible through `session.list`. The gateway asks the optional `ctx.sessionQuery` service for globally ranked current-surface user, assistant, and steering matches, consumes that stream until it has at most 20 visible session/snippet pairs plus one lookahead, and revalidates every hit against the list-derived authorization set before returning it. Provider pages start at 20 hits; when a first-page request rejects that limit, the gateway probes 10, 5, 2, then 1 and retains the learned size for continuation and stale-generation restarts. Returned snippets contain at most 240 Unicode code points, and the response schema independently enforces that bound at each client boundary. Keeping the authorization set in Host memory avoids SQLite's variable ceiling for large valid corpora without weakening visibility or ranking. @@ -56,7 +60,7 @@ The `settings.*`, `credentials.*`, and `llm.*` domains are the configuration-pag ## Carrier layer (`/client` + root) -`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh run` is a direct core entry point and does not mount this package. +`AbstractApiClient` holds every protocol invariant — rpcId minting, envelope wrap/unwrap, zod parsing, SSE frame decoding, unary timeout, microtask-batched envelope observation (`subscribeEnvelopes`) — while platform subclasses supply only the `doFetch` transport aspect. `InProcessApiClient` over `toFetchHandler(api)` remains the isomorphic point for callers and carrier tests that need the full wire serialization/validation path without a network. Product `dsh --profile headless` is a direct core entry point and does not mount this package. ## Model Experience diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index f001866af2..bdb26a6383 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -28,6 +28,8 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 `session.history` 的尾页(不带 `beforeSeq`)额外携带一个可选的 `projections` 块——`ctx.sessionProjections`(`@deepseek-ai/dsh-session-projection`)上每个已注册单元的水位线快照,`asOfSeq` = 这些值共同反映到的最后一个事件 seq(空日志为 `-1`)。网关还订阅注册表的变更流,为每个状态发生变化的单元生成一个 `session/projection` mux 帧(`{sessionId, key, value, seq}`——实时推送状态,绝不入日志;客户端按 seq 高者胜维护一个按会话的通用值仓)。载体不持有任何领域知识(每个值在注册表内部已过其单元自己的 schema;协议 schema 对 `values`/`value` 保持宽松);loadOlder 页永不携带该块,未装注册表的组合则两个面都不提供。 +会话日志导出是宿主侧的下载面,不是 RPC:`GET /api/session.export?sessionId=…&includeDescendants=true` 流式返回一个 ZIP,其中每个文件都是会话存储工件的逐字原文(持久化后端的 `readRaw`——按物理编码解码的确切持久化字节,绝非从解析后事件重建),根会话放在其原始基础文件名下,每个子代理后代放在 `subagents//` 下,每个被任何包含的日志引用的图片放在 `media/.` 下(从附件存储读取并校验;共享图片只出现一次)。压缩在宿主侧用 fflate 的流式 Zip API 完成,响应边生成边分块写出,宿主从不把整个归档放进单个缓冲区,且每当响应队列填满时生产会让出,慢消费者因此只产生有界的积压(fflate 的回调是同步的——让出点是唯一的背压手段)。它要求同时挂载持久化、session-query 与附件服务:任一缺失应答 500,根会话缺失应答 404,后代缺少存储工件或引用的图片无法读取则整个流失败(fail-loud,绝不静默少导出)。端点由传输层挂载,`ApiProxy.downloads.sessionLog` 实现它。 + 会话标题与其他所有领域一样搭乘这对通用投影机制——历史尾页的 `projections` 块外加 `title` 键下的 `session/projection` 帧。标题不会加入 `session.list`;冷会话在其中仍只有元数据,直到打开或恢复操作附加其日志。`session.rename` 接受用户显式标题(冷会话先恢复),委托给 `ctx.sessionTitle.rename`——被接受的 `session/title` 事件将标题钉住、不再被自动生成覆盖——并返回规范化后的标题及其事件 seq,让 client 在推送帧到达前就结算自己的 `title` 投影格;规范化后为空的标题返回 `title-invalid`。 `session.fork` 将可选事件锚点映射到该锚点处或其后的首个 `turn/end`,使消息操作可包含该消息所在的完整轮次。锚点省略或超过末尾时,选择最后一个已完成轮次;若锚点已在日志中,而其所在轮次仍开放,则返回 `fork-unavailable`,不会向较早位置裁剪。发布后的子会话会先继承源会话的种子历史、cwd、日志中最新的 `ModelSelection` 及谱系,再加入源 Workspace。如果附加到 Workspace 失败,`workspace-attach-failed` 会携带已发布的子会话 id,供客户端对账。[SessionStore fork 决策](../../../.agents/notes/implemented/feature/2026-06-30-session-store-fork-api.md)记录了为何锚点要映射到该 `turn/end`。 @@ -36,6 +38,8 @@ Settings 分节中的 `reasoningEffort` 在 agent-default-model 插件配置中 待处理的 queued 输入属于实时控制平面约定,而非对话历史。网关根据持久 `agent/inbox/spliced` 变更派生完整的 `next-turn` 队列,并在每次变更后及重连时广播权威 `session/queue` 快照;待处理的 `next-step` steering(中途引导)不进入此 Web 投影。在 `next-step` 内,用户来源的消息携带 `steering` placement,而注入上下文(审批通知、任务完成、附加快照)携带 `context`,领取前不对外呈现。面向单条消息的 `agent/inbox/inserted`、`claimed` 与 `discarded` 通知仍供生命周期观察方使用,但不用于构建队列视图。`session.updateQueue` 通过 `MessageId` 寻址单个项;编辑和移除经已挂载 Agent 的 `Inbox.splice()` 修改队列。claim 的纯删除 splice 会在 pre-step 准入前赢得竞态,因此之后的操作返回 `queue-item-not-found`。`session.cancel` 仅中止活动轮次并保留待处理 inbox 工作;取消达到完全停稳且结束中的轮次完成 flush 后,AgentLoop 按 FIFO 顺序认领下一条可唤醒消息,浏览器绝不重发或提升它。队列操作绝不恢复冷会话,客户端也绝不根据轮次或状态事件推断某项已退出队列。 +后台任务沿用同一种实时推送姿态。当组合中有 `ctx.tasks` 时,网关订阅它的变更订阅,并在注册表每一次改变某个会话可见内容的提交后——注册、转入 stopping、结算,以及 owner 销毁时的移除——广播一份完整的 `session/tasks` 快照,另外为每个已经有任务的会话发送订阅 baseline(没有 baseline 即表示空集;把集合清空的那次变更仍然发送 `[]`)。带 owner 的变更通过那个确切的 `Agent` 读取,因此推送在其 scope 拆除期间依然正确;baseline 读 `ctx.agents.get(sessionId)`,对没有活体 Agent 的会话只得到无主任务,且绝不恢复冷会话。无主变更向每一个已订阅会话扇出,因为无主任务对所有调用方可见。线路上的 `TaskView` 丢弃 `ownerSession`、`reported` 和 `outputLimitBytes`:第一个由帧自身的 `sessionId` 携带,另外两个分别是内部通知位和模型呈现策略。没有该注册表的组合不发出这类帧。 + Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.create({ path })` 会接纳已有的规范目录,并允许由 basename 派生的标题重复。`workspace.delete` 只移除 Workspace 注册记录,`session.create` 接受可选的预分配 Session id,`host/workspace-changed`、`host/workspace-removed` 与 `host/session-added` 则以任意到达顺序携带已提交的增量。`workspace.archiveSession` 向注册表级全局归档集合添加一个会话,并应答完整的更新后集合;`workspace.list` 携带该集合作为重连基线,`host/archived-sessions-changed` 在每次持久变更后推送完整快照。归档只把会话从各分组视图中隐藏,不触碰其日志和 workspace 记账;既非实时也未持久化的会话以 `session-not-found` 失败。删除注册记录会保留目录和会话日志;相关 Session 仍留在 `session.list` 中,并进入 Ungrouped。`SessionSummary.blank` 与 `host/session-added` 帧携带派生的零事件位:客户端隐藏空白会话并按 workspace 复用它们,在首个 `host/session-status(running:true)` 时翻转 blank,并以 `session.list` 作为重连权威;冷会话摘要永远不是空白:惰性持久化让从未追加过事件的会话根本不出现在 `list()` 中。 `session.search` 是以 `session.list` 所列会话为范围的有界内容搜索投影。网关向可选的 `ctx.sessionQuery` 服务请求全局排序后的当前内容视图中的 user、assistant 和 steering 匹配项,并持续消费该结果流,直到获得至多 20 个可见会话/snippet 对及一个前瞻项;返回前仍会依据从列表推导的授权集合重新校验每个命中。提供方分页初始请求 20 个命中;如果第一页请求因这一上限被拒绝,网关会依次探测 10、5、2、1,并在续传和陈旧世代重启中沿用探测所得的页面大小。返回的 snippet 最多包含 240 个 Unicode 码点,响应 schema 则会在每个客户端边界独立强制执行该上限。将授权集合保留在宿主内存中,可在不削弱可见性或排序的前提下避开有效大型语料库的 SQLite 变量上限。 @@ -56,7 +60,7 @@ Workspace 列表与 Session 列表是相互独立的重连基线。`workspace.cr ## 载体层(`/client` + 根路径) -`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh run` 是直连 core 的入口,不挂载本包。 +`AbstractApiClient` 持有全部协议不变量:签发 rpcId、包装/解包信封、Zod 解析、SSE 帧解码、一元请求超时,以及按微任务批处理的信封观测(`subscribeEnvelopes`);平台子类只提供 `doFetch` 传输环节。`InProcessApiClient` 以 `toFetchHandler(api)` 为基础,仍是同构接点:它运行完整的协议序列化与校验路径而不经过网络,供需要该路径的调用方和载体测试使用。产品的 `dsh --profile headless` 是直连 core 的入口,不挂载本包。 ## 模型体验 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 42f69c6211..1145ee492b 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-host-apiproxy", "description": "API gateway: the ApiProxy contract (api/), the fetch carrier pair (fetch/), and the host-side gateway plugin providing ctx.apiProxy", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/apiproxy" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -58,17 +65,19 @@ "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", + "fflate": "^0.8.2", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-agent-presets": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent-presets": "workspace:^", @@ -77,6 +86,6 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index 3626ef9960..c4e0a16756 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -6,7 +6,7 @@ import { randomUUID } from 'node:crypto' import { mkdir, stat } from 'node:fs/promises' import { dirname } from 'node:path' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { installModelSelection } from '@deepseek-ai/dsh-agent' import type { Agent, ModelSelection, ModelSelectionRef, AgentOptions, AgentStatus } from '@deepseek-ai/dsh-agent' import { AGENT_DEFAULT_MODEL_SETTINGS_NAMESPACE } from '@deepseek-ai/dsh-agent-default-model' @@ -39,9 +39,16 @@ import type { ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, PromptContentPart, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem, - QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, + QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' +import { + sessionLogExportDeps, + sessionLogZipFilename, + streamSessionLogZip, + type SessionLogExportReady, +} from './session-export.ts' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' import { SESSION_SEARCH_RESULT_LIMIT, SESSION_SEARCH_SNIPPET_MAX_CODE_POINTS, @@ -49,6 +56,9 @@ import { } from './api/session-search.ts' // Type-only: resolves `ctx.get('sessionProjections')` to the projection registry. import type {} from '@deepseek-ai/dsh-session-projection' +// Type-only: resolves `ctx.get('tasks')` to the background task registry. +import type {} from '@deepseek-ai/dsh-tasks' +import type { TaskSnapshot } from '@deepseek-ai/dsh-tasks' // Type-only: resolves `ctx.get('sessionProjectionCache')` (the cold listing column). import type {} from '@deepseek-ai/dsh-session-projection-cache' // GoalError narrows domain rejections to their stable codes at the wire boundary. @@ -406,6 +416,22 @@ function subscribeSession(queue: FrameQueue>, session: Sess queue.push(frame({ type: 'session/subscribed', sessionId: session.id, lastSeq: session.seq - 1 })) } +/** + * Project registry snapshots onto the wire view, dropping the three internal + * fields {@link TaskView} documents as absent. + */ +function taskViews(snapshots: readonly TaskSnapshot[]): TaskView[] { + return snapshots.map(task => ({ + id: task.id, + kind: task.kind, + label: task.label, + status: task.status, + ...task.detail === undefined ? {} : { detail: task.detail }, + startedAt: task.startedAt, + ...task.finishedAt === undefined ? {} : { finishedAt: task.finishedAt }, + })) +} + /** * Whether the session's conversation has started: no turn has run yet (a * turn is one model-loop execution). Standalone plugin events — command @@ -686,6 +712,16 @@ function historyPage( * registry). An absent registry means the deployment has no projection seam: * the whole block is absent and clients treat every key as capability-absent. */ +/** + * Which session a transcript read is served from. An attached session is the + * live object and keeps appending, so its events and projection baseline are + * read together in one synchronous step; a detached one is already a frozen + * inspection. + */ +type HistorySource = + | { readonly kind: 'attached'; readonly session: Session } + | { readonly kind: 'detached'; readonly header: SessionHeader; readonly events: SessionEvent[] } + function projectionsFor(ctx: Context, session: Session): SessionProjectionsBlock | undefined { const registry = ctx.get('sessionProjections') if (registry === undefined) return undefined @@ -1329,24 +1365,55 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro return undefined } - /** Read one transcript cut and optional projection baseline without acquiring an Agent owner. */ - async function historyStateFor( - sessionId: SessionId, - includeProjections: boolean, - ): Promise<{ header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock }> { + /** + * Resolve which session one transcript read is served from, without + * acquiring an Agent owner. This is the read's only asynchronous step + * besides ensuring the composition; {@link historyCutOf} takes the cut. + * @param sessionId - the transcript being read. + * @returns the attached session, or the inspected detached header and events. + * @throws {@link ApiRemoteSessionNotFound} when no project-backed session has that identity. + */ + async function historySourceFor(sessionId: SessionId): Promise { const attached = ctx.sessions.get(sessionId) - if (attached !== undefined) { - const events = [...attached.events] - const projections = includeProjections ? projectionsFor(ctx, attached) : undefined - return { header: attached.header, events, ...projections === undefined ? {} : { projections } } - } + if (attached !== undefined) return { kind: 'attached', session: attached } const inspected = await inspectServable(sessionId) - const projections = includeProjections ? detachedProjectionsFor(ctx, inspected.events) : undefined - return { - header: inspected.meta, - events: inspected.events, - ...projections === undefined ? {} : { projections }, + return { kind: 'detached', header: inspected.meta, events: inspected.events } + } + + /** + * The header and events {@link presenterScopeFor} reads to decide which + * composition a transcript ran under. + * @param source - the live or detached session this read is served from. + * @returns that session's creation header and its events. + */ + function sourceSession(source: HistorySource): PresetBearingSession { + if (source.kind === 'detached') return { header: source.header, events: source.events } + return { header: source.session.header, events: source.session.events } + } + + /** + * One transcript cut: the events and the projection baseline that describe + * the SAME log position. + * + * Synchronous, and the two reads sit next to each other, because an attached + * session keeps appending: an `await` between them would serve events cut at + * N beside a baseline folded to N+1, which is one response describing two + * moments. The caller does its awaiting before this call. + * @param source - the live or detached session this read is served from. + * @param includeProjections - whether the caller asked for the baseline (a tail page does). + * @returns the events and, when asked, the baseline for that same position. + */ + function historyCutOf( + source: HistorySource, + includeProjections: boolean, + ): { events: SessionEvent[]; projections?: SessionProjectionsBlock } { + if (source.kind === 'detached') { + const projections = includeProjections ? detachedProjectionsFor(ctx, source.events) : undefined + return { events: source.events, ...projections === undefined ? {} : { projections } } } + const events = [...source.session.events] + const projections = includeProjections ? projectionsFor(ctx, source.session) : undefined + return { events, ...projections === undefined ? {} : { projections } } } /** @@ -2006,9 +2073,22 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro async history(request) { const { sessionId, beforeSeq, maxMessages } = request.payload - let state: { header: SessionHeader; events: SessionEvent[]; projections?: SessionProjectionsBlock } try { - state = await historyStateFor(sessionId, beforeSeq === undefined) + const source = await historySourceFor(sessionId) + // Both awaits happen BEFORE the cut. Ensuring the recorded + // composition's standing mount is what registers its projection + // units, so a first cold read would otherwise serve a baseline + // missing every preset-owned key; and an attached session keeps + // appending, so awaiting between the two reads would pair events cut + // at N with a baseline folded to N+1. + const scope = await presenterScopeFor(sessionId, sourceSession(source)) + const cut = historyCutOf(source, beforeSeq === undefined) + const page = historyPage(ctx, cut.events, beforeSeq, maxMessages, scope) + return ok(request, { + events: page.events, + hasMore: page.hasMore, + ...cut.projections === undefined ? {} : { projections: cut.projections }, + }) } catch (error: unknown) { if (error instanceof SessionNotFound) { return err(request, { code: 'session-not-found', message: error.message, details: { sessionId } }) @@ -2019,12 +2099,6 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro details: {}, }) } - const page = historyPage(ctx, state.events, beforeSeq, maxMessages, await presenterScopeFor(sessionId, state)) - return ok(request, { - events: page.events, - hasMore: page.hasMore, - ...state.projections === undefined ? {} : { projections: state.projections }, - }) }, async models(request) { @@ -3212,6 +3286,19 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro queue.push(frame({ type: 'session/queue', sessionId: session.id, items: queueItems(agent) })) } } + // Background-task baseline. `ctx.agents.get` is the non-resuming read: + // a session with no live Agent owns no tasks, so it correctly sees only + // the unowned ones, and listing never revives a cold session. An empty + // set sends nothing — absence is how the client reads "no tasks". + const tasks = ctx.get('tasks') + if (tasks !== undefined) { + for (const session of ctx.sessions.list()) { + const views = taskViews(tasks.list(ctx.agents.get(session.id))) + if (views.length > 0) { + queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views })) + } + } + } // Per-session open-call table for result-view pairing. Bounded by the // per-turn call count: entries clear on turn/end; a table miss (stream // opened mid-turn) backscans the session's in-memory events instead. @@ -3239,10 +3326,36 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }), ctx.on('session/created', (session: Session) => { subscribeSession(queue, session) + // The subscribe frame clears the client's task mirror, and a + // session born after the stream opened missed the baseline loop. + // Unowned tasks are visible to it from birth, so without this it + // would show none until the next registry change. + const views = tasks === undefined ? [] : taskViews(tasks.list(ctx.agents.get(session.id))) + if (views.length > 0) { + queue.push(frame({ type: 'session/tasks', sessionId: session.id, tasks: views })) + } }), ctx.on('session/disposed', (session: Session) => { openCalls.delete(session.id) }), + ...tasks === undefined ? [] : [tasks.onTasksChanged((owner) => { + if (owner !== undefined) { + // The exact owner instance the fence compares against, so the + // push stays correct even while that Agent's scope is tearing + // down and a lookup by id would already miss. + queue.push(frame({ type: 'session/tasks', sessionId: owner.id, tasks: taskViews(tasks.list(owner)) })) + return + } + // An unowned task is visible to every caller, so every subscribed + // session's set changed with it. + for (const session of ctx.sessions.list()) { + queue.push(frame({ + type: 'session/tasks', + sessionId: session.id, + tasks: taskViews(tasks.list(ctx.agents.get(session.id))), + })) + } + })], ] return queue.iterate(signal, () => { muxQueues.delete(queue) @@ -3364,6 +3477,46 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro }, }, + downloads: { + async sessionLog(request, signal) { + // Clean error path first: missing services answer 500 and a missing + // root artifact 404 before any zip byte is produced. The root content + // read here is reused as the first zip entry, so nothing is read twice. + const deps = sessionLogExportDeps(ctx) + if (deps.sessionQuery === undefined || deps.sessionPersistence === undefined || deps.attachments === undefined) { + return new Response( + 'session log export is unavailable: missing session-query, session-persistence, or attachments service', + { status: 500 }, + ) + } + const ready: SessionLogExportReady = { + sessionQuery: deps.sessionQuery, + sessionPersistence: deps.sessionPersistence, + attachments: deps.attachments, + } + let root: SessionRawArtifact | undefined + try { + root = await deps.sessionPersistence.readRaw(request.sessionId, signal) + } catch { + // Backend read failure: answer 500 without echoing the error, which + // may carry absolute host paths into the browser error bar. + return new Response('session log export failed to read the stored artifact', { status: 500 }) + } + if (root === undefined) { + return new Response('session not found', { status: 404 }) + } + return new Response( + streamSessionLogZip(ready, root, request.sessionId, request.includeDescendants === true, signal), + { + headers: { + 'content-type': 'application/zip', + 'content-disposition': `attachment; filename="${sessionLogZipFilename(request.sessionId)}"`, + }, + }, + ) + }, + }, + respond(message: ClientResponse): Promise { // Route by the echoed rpcId (the wire correlation): approvals first, // then questions — the two registries share one id space of UUIDs. diff --git a/packages/host/apiproxy/src/api/downloads.schema.ts b/packages/host/apiproxy/src/api/downloads.schema.ts new file mode 100644 index 0000000000..8a5b371e7f --- /dev/null +++ b/packages/host/apiproxy/src/api/downloads.schema.ts @@ -0,0 +1,26 @@ +/** + * downloads domain zod schemas. The GET download surface has no wire + * envelope: the request arrives as query parameters (all strings), so its + * request schema parses the raw query-parameter object into the method's + * exact request shape. SessionId brand cast point: sessionIdSchema, and only + * there (hosted in sessions.schema like every other cast). + */ + +import { z } from 'zod' +import type { DownloadsApi } from './downloads.ts' +import { sessionIdSchema } from './sessions.schema.ts' + +/** + * session.export query params → the sessionLog request. `includeDescendants` + * accepts exactly `true`/`false`/absent; any other value is rejected (400) so + * a misspelled flag cannot silently under-export. + */ +export const sessionLogQuerySchema = z + .object({ + sessionId: sessionIdSchema, + includeDescendants: z.union([z.literal('true'), z.literal('false')]).optional(), + }) + .transform(query => ({ + sessionId: query.sessionId, + ...(query.includeDescendants === 'true' ? { includeDescendants: true } : {}), + })) satisfies z.ZodType[0]> diff --git a/packages/host/apiproxy/src/api/downloads.ts b/packages/host/apiproxy/src/api/downloads.ts new file mode 100644 index 0000000000..d0e6138b6a --- /dev/null +++ b/packages/host/apiproxy/src/api/downloads.ts @@ -0,0 +1,25 @@ +/** + * downloads domain contract: host-only download surfaces — the GET-download + * channel family, the mirror of the SSE-stream `events` domain. No wire + * envelope: the carrier's GET routes answer these directly, and the browser + * `IApiClient` never exposes them. + */ + +import type { SessionId } from '@deepseek-ai/dsh-session/types' + +/** Host-only download surfaces (no wire envelope; absent from IApiClient). */ +export interface DownloadsApi { + /** + * Stream one session-log ZIP — the root artifact verbatim plus each subagent + * descendant's — as an attachment response. The carrier's GET route answers + * this directly; the browser never calls it. + * @param request - the root session id and whether to include descendants. + * @param signal - cancellation for the underlying reads. + * @returns the ZIP attachment response; missing services answer 500 and a + * missing root session 404 before any byte is produced. + */ + sessionLog( + request: { sessionId: SessionId; includeDescendants?: boolean }, + signal: AbortSignal, + ): Promise +} diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index c06efb9057..02516c13bb 100644 --- a/packages/host/apiproxy/src/api/events.schema.ts +++ b/packages/host/apiproxy/src/api/events.schema.ts @@ -13,6 +13,7 @@ import { approvalRequestIdSchema } from './approvals.schema.ts' import { contentBlockSchema, messageIdSchema, sessionEventSchema, sessionIdSchema, toolEventViewSchema, } from './sessions.schema.ts' +import { taskViewSchema } from './tasks.schema.ts' import { workspaceIdSchema, workspaceViewSchema } from './workspace.schema.ts' /** Question fields validated strictly against core dsh-user-interaction. */ @@ -58,6 +59,7 @@ export const muxFrameSchema = z.discriminatedUnion('type', [ message: messageSchema, })), }), + z.object({ type: z.literal('session/tasks'), sessionId: sessionIdSchema, tasks: z.array(taskViewSchema) }), // value stays wide: it already passed its unit's own schema on the host, // and deep-validating here would import every domain's schema into the carrier. z.object({ type: z.literal('session/projection'), sessionId: sessionIdSchema, key: z.string().min(1), value: z.unknown(), seq: z.number().int().nonnegative() }), diff --git a/packages/host/apiproxy/src/api/events.ts b/packages/host/apiproxy/src/api/events.ts index 351ea4115e..73bb9d8bc2 100644 --- a/packages/host/apiproxy/src/api/events.ts +++ b/packages/host/apiproxy/src/api/events.ts @@ -14,6 +14,7 @@ import type { CallId } from '@deepseek-ai/dsh-llm/brand' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session/types' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools/presentation' import type { RpcError, RpcId, RpcRequest } from './rpc.ts' +import type { TaskView } from './tasks.ts' import type { WorkspaceView } from './workspace.ts' // Client-side consumers take the render-intent vocabulary from the contract; @@ -81,6 +82,20 @@ export type MuxFrame = * in QueueDock, while pending steering renders at the conversation tail. */ | { type: 'session/queue'; sessionId: SessionId; items: QueuedInboxItem[] } + /** + * Complete set of background tasks this session can see, after every registry + * commit that changes it: registration, the stopping transition, settlement, + * and owner-disposal removal. The registry is process-local and holds no + * durable event, so — exactly like `session/queue` — the whole snapshot is + * what makes a start, a kill, a reconnect, and a second tab converge on one + * authoritative value. + * + * Sent as a subscription baseline only for a session that currently has + * tasks; an absent key means an empty set. A change that empties the set + * still sends `[]`, since that transition is the only one absence cannot + * express. + */ + | { type: 'session/tasks'; sessionId: SessionId; tasks: TaskView[] } /** * One projection unit's finished value changed (session-projection RFC). * Live push state, never logged — replay recomputes on the host (the diff --git a/packages/host/apiproxy/src/api/index.ts b/packages/host/apiproxy/src/api/index.ts index e8eb3f3272..3247886bfe 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -16,6 +16,7 @@ import type { GoalsApi } from './goals.ts' import type { SettingsApi } from './settings.ts' import type { CredentialsApi } from './credentials.ts' import type { LlmApi } from './llm.ts' +import type { DownloadsApi } from './downloads.ts' import type { ClientResponse, RpcReceipt } from './rpc.ts' /** Root interface of the unified API surface. New client-request domain = one new file pair + one field here + one map row. */ @@ -32,6 +33,8 @@ export interface ApiProxy { settings: SettingsApi credentials: CredentialsApi llm: LlmApi + /** Host-only download surfaces (GET, no wire envelope); absent from IApiClient. */ + downloads: DownloadsApi /** Response entry for server-requests (client-response, echoing their rpcId); not a domain method (four-quadrant model). */ respond(message: ClientResponse): Promise } @@ -39,15 +42,15 @@ export interface ApiProxy { // ---- Domain interfaces and payload entities ---- export type { HistoryEntry, ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, - ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, SessionProjectionsBlock, - SessionSearchItem, - SessionsApi, SessionSummary, + ModelReasoningEffort, ModelSelection, PromptContentPart, QueueAction, SessionModels, + SessionProjectionsBlock, SessionSearchItem, SessionsApi, SessionSummary, } from './sessions.ts' export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { SubagentAddress, SubagentCatalog, SubagentInterruptReceipt, SubagentListEntry, SubagentPromptReceipt, SubagentsApi, } from './subagents.ts' +export type { TaskView } from './tasks.ts' export type { WorkspaceApi, WorkspaceId, WorkspaceView } from './workspace.ts' export type { CommandsApi, CommandDescriptor } from './commands.ts' export type { SkillsApi, SkillEntry } from './skills.ts' @@ -57,6 +60,7 @@ export type { GoalsApi, GoalId, GoalRef } from './goals.ts' export type { SettingsApi, SettingsNamespaceView, SettingsPathOpView, SettingsSecretView } from './settings.ts' export type { CredentialsApi, CredentialView } from './credentials.ts' export type { ConfigurableProviderView, DiscoveredModelView, LlmApi } from './llm.ts' +export type { DownloadsApi } from './downloads.ts' export type { ApprovalResponsePayload } from './approvals.ts' export type { QuestionResponsePayload } from './questions.ts' diff --git a/packages/host/apiproxy/src/api/sessions.schema.ts b/packages/host/apiproxy/src/api/sessions.schema.ts index 81e150bc20..5c4647769a 100644 --- a/packages/host/apiproxy/src/api/sessions.schema.ts +++ b/packages/host/apiproxy/src/api/sessions.schema.ts @@ -45,6 +45,7 @@ export const sessionEventSchema = z.object({ data: z.unknown(), sourceEventSeqs: z.array(z.number()).optional(), surfaceOp: z.unknown().optional(), + ignorable: z.literal(true).optional(), }) as unknown as z.ZodType /** SessionSummary row of session.list (`projections` reuses the history block's shape and schema). */ diff --git a/packages/host/apiproxy/src/api/tasks.schema.ts b/packages/host/apiproxy/src/api/tasks.schema.ts new file mode 100644 index 0000000000..263b895b45 --- /dev/null +++ b/packages/host/apiproxy/src/api/tasks.schema.ts @@ -0,0 +1,33 @@ +/** + * tasks domain zod schemas: the branded task id and the wire view carried by + * `session/tasks` frames. + */ + +import { z } from 'zod' +import type { TaskId } from '@deepseek-ai/dsh-tasks/brand' +import type { TaskView } from './tasks.ts' +import type { Wire } from './rpc.schema.ts' + +/** TaskId: one brand cast after non-empty string validation. */ +export const taskIdSchema = z.string().min(1) as unknown as z.ZodType + +/** + * One wire task view. `kind` stays an open string because producer plugins + * extend the registry's kind map by declaration merging, so the closed set is + * not knowable at this boundary. + */ +export const taskViewSchema = z.object({ + id: taskIdSchema, + kind: z.string().min(1), + label: z.string().min(1), + status: z.union([ + z.literal('running'), + z.literal('stopping'), + z.literal('completed'), + z.literal('killed'), + z.literal('failed'), + ]), + detail: z.string().optional(), + startedAt: z.number().int().nonnegative(), + finishedAt: z.number().int().nonnegative().optional(), +}) satisfies z.ZodType> diff --git a/packages/host/apiproxy/src/api/tasks.ts b/packages/host/apiproxy/src/api/tasks.ts new file mode 100644 index 0000000000..f327b7cc1d --- /dev/null +++ b/packages/host/apiproxy/src/api/tasks.ts @@ -0,0 +1,36 @@ +/** + * Browser-safe background-task domain contract. The registry's live records + * never cross the wire; a view is the subset a human list needs, minted fresh + * per push. + */ + +import type { TaskId } from '@deepseek-ai/dsh-tasks/brand' + +/** + * One background task as the client sees it. + * + * Three registry fields are deliberately absent. `ownerSession` is redundant + * beside the frame's own `sessionId`; `reported` is an internal notice-delivery + * bit with no user meaning; `outputLimitBytes` is producer-owned model + * presentation policy that never reaches a human surface. + */ +export interface TaskView { + /** Registry-issued `-N` identity, stable for the task's whole life. */ + id: TaskId + /** + * Producer kind (`bash`, `pwsh`, `pty-send`, `subagent`, …). Kept as a bare + * string because producer plugins extend the kind map by declaration merging, + * so no client build can enumerate the closed set. + */ + kind: string + /** Producer-supplied one-line label: the command, or the delegation description. */ + label: string + /** Current lifecycle state. */ + status: 'running' | 'stopping' | 'completed' | 'killed' | 'failed' + /** Kind-specific status detail ('exit code: 3'), present once the producer supplied one. */ + detail?: string + /** Epoch ms when the task was registered. */ + startedAt: number + /** Epoch ms when the task settled; absent while live. */ + finishedAt?: number +} diff --git a/packages/host/apiproxy/src/fetch/handler.ts b/packages/host/apiproxy/src/fetch/handler.ts index f62a3584e7..1e902f059e 100644 --- a/packages/host/apiproxy/src/fetch/handler.ts +++ b/packages/host/apiproxy/src/fetch/handler.ts @@ -9,6 +9,7 @@ import { randomUUID } from 'node:crypto' import type { z } from 'zod' import type { ApiProxy, MuxFrame, HostFrame } from '../api/index.ts' +import { sessionLogQuerySchema } from '../api/downloads.schema.ts' import type { RequestPayload, ResponseValue, RpcMethodMap } from '../api/rpc-map.ts' import type { ClientRequest, RpcError, RpcRequest, RpcResponse, ServerRequest, ServerResponse } from '../api/rpc.ts' import { RpcId } from '../api/rpc.ts' @@ -249,12 +250,23 @@ export function toFetchHandler(api: ApiProxy): { fetch: typeof fetch } { const url = new URL(req.url) const path = url.pathname + // No-envelope GET channel surface (SSE streams + host-only download): + // physical routes that answer directly, without a wire envelope. if (path === '/api/events.mux' && req.method === 'GET') { return sseResponse(api.events.mux({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) } if (path === '/api/events.host' && req.method === 'GET') { return sseResponse(api.events.host({ rpcId: RpcId(randomUUID()), payload: {} }, req.signal)) } + if (path === '/api/session.export' && req.method === 'GET') { + // Query params are a different boundary from the POST envelope, but + // the request still casts its brands only through the domain schema. + const parsed = sessionLogQuerySchema.safeParse(Object.fromEntries(url.searchParams)) + if (!parsed.success) { + return new Response('missing or invalid sessionId query parameter', { status: 400 }) + } + return api.downloads.sessionLog(parsed.data, req.signal) + } if (req.method !== 'POST' || !path.startsWith('/api/')) { return new Response('not found', { status: 404 }) diff --git a/packages/host/apiproxy/src/index.ts b/packages/host/apiproxy/src/index.ts index 43ce9e2df4..6bb062dcad 100644 --- a/packages/host/apiproxy/src/index.ts +++ b/packages/host/apiproxy/src/index.ts @@ -12,8 +12,8 @@ * service; sessions that have already logged a selection remain unchanged. */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent-default-model' import type { ApiProxy } from './api/index.ts' import { createApiProxy } from './api-proxy.ts' @@ -26,7 +26,7 @@ export type { IApiClient } from './fetch/client.ts' export { createApiProxy } from './api-proxy.ts' export type { ApiProxyDefaults } from './api-proxy.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** The host-side ApiProxy implementation (the transport-agnostic gateway face). */ apiProxy: ApiProxy @@ -72,6 +72,7 @@ export class ApiProxyService extends Service implements ApiProxy { readonly credentials: ApiProxy['credentials'] readonly llm: ApiProxy['llm'] readonly events: ApiProxy['events'] + readonly downloads: ApiProxy['downloads'] readonly respond: ApiProxy['respond'] constructor(ctx: Context, config: Config) { @@ -94,6 +95,7 @@ export class ApiProxyService extends Service implements ApiProxy { this.credentials = api.credentials this.llm = api.llm this.events = api.events + this.downloads = api.downloads // createApiProxy returns closures (no `this` capture), so the bind is // behavior-neutral. this.respond = api.respond.bind(api) diff --git a/packages/host/apiproxy/src/invariant.ts b/packages/host/apiproxy/src/invariant.ts index a96b5d081d..ac21250e90 100644 --- a/packages/host/apiproxy/src/invariant.ts +++ b/packages/host/apiproxy/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-host-apiproxy' diff --git a/packages/host/apiproxy/src/session-export.ts b/packages/host/apiproxy/src/session-export.ts new file mode 100644 index 0000000000..73026be20a --- /dev/null +++ b/packages/host/apiproxy/src/session-export.ts @@ -0,0 +1,357 @@ +/** + * Host-side session-log download: streams one ZIP archive whose files are the + * sessions' stored artifact text verbatim plus every referenced media object. + * The root artifact sits under its original base name (`session.jsonl`); each + * subagent descendant under `subagents//`; each image referenced + * by any included log under `media/.` (content-addressed, + * so one archive never duplicates a shared image). No manifest is written — + * every file is byte-identical to the backend's durable artifact or attachment + * store and self-describing through its own header line or media type. + * Compression runs on the host with fflate's streaming Zip API, so the archive + * bytes are produced incrementally and the host never holds the whole archive + * in one buffer; production yields to the consumer whenever the response queue + * fills past its high-water mark, so a slow consumer bounds the accumulation + * instead of piling up the whole archive (fflate's callback is synchronous — + * this drain point is the only backpressure available). + * @module + */ + +import { Zip, ZipDeflate } from 'fflate' +import type { Context } from '@deepseek-ai/cordis' +import type { AttachmentStore, ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import type { SessionLineageNode, SessionQueryService } from '@deepseek-ai/dsh-session-query' +import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionPersistence, SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' + +/** The services a session-log export needs (absent → the export is unavailable). */ +export interface SessionLogExportDeps { + readonly sessionQuery: SessionQueryService | undefined + readonly sessionPersistence: SessionPersistence | undefined + readonly attachments: AttachmentStore | undefined +} + +/** The export services narrowed to the mounted ones streaming actually reads. */ +export interface SessionLogExportReady { + readonly sessionQuery: SessionQueryService + readonly sessionPersistence: SessionPersistence + readonly attachments: AttachmentStore +} + +/** + * Resolve the persistence, session-query, and attachment services a log export needs. + * @param ctx - the composed host context. + * @returns the export services (absent when the deployment does not mount them). + */ +export function sessionLogExportDeps(ctx: Context): SessionLogExportDeps { + return { + sessionQuery: ctx.get('sessionQuery'), + sessionPersistence: ctx.get('sessionPersistence'), + attachments: ctx.get('attachments'), + } +} + +/** One exported file: a stored artifact text or one referenced media object. */ +export type SessionLogZipEntry = + | { readonly path: string; readonly content: string } + | { readonly path: string; readonly data: Uint8Array } + +/** Zip extension for each accepted raster media type. */ +const MEDIA_TYPE_EXTENSIONS: Record = { + 'image/png': 'png', + 'image/jpeg': 'jpg', + 'image/webp': 'webp', + 'image/gif': 'gif', +} + +/** + * The zip path for one media object: content-addressed by the opaque + * attachment id so shared images land once and the id in the log maps back to + * the archive entry without a manifest. + * @param ref - the durable reference from a session log. + * @returns the archive path. + */ +function mediaEntryPath(ref: ImageAttachmentRef): string { + return `media/${String(ref.attachmentId)}.${MEDIA_TYPE_EXTENSIONS[ref.mediaType]}` +} + +/** + * Collect every image reference inside one content array, descending into + * nested tool results the way the live attachment route does. + * @param content - an event content array (or nested tool-result content). + * @param refs - the dedupe map being filled (keyed by attachment id). + */ +function collectImageRefs(content: unknown, refs: Map): void { + if (!Array.isArray(content)) return + const pending: unknown[] = [] + for (const item of content) pending.push(item) + while (pending.length > 0) { + const value = pending.pop() + if (typeof value !== 'object' || value === null || Array.isArray(value)) continue + const block = value as { type?: unknown; attachment?: unknown; content?: unknown } + if (block.type === 'image' && typeof block.attachment === 'object' && block.attachment !== null) { + const ref = block.attachment as ImageAttachmentRef + refs.set(String(ref.attachmentId), ref) + } + if (Array.isArray(block.content)) { + for (const item of block.content) pending.push(item) + } + } +} + +/** + * Collect every image reference one session event carries, across the same + * carriers the live attachment route scans (direct content, message content, + * inserted messages, and completed assistant chunk blocks). + * @param event - one parsed JSONL event object. + * @param refs - the dedupe map being filled (keyed by attachment id). + */ +function collectEventImageRefs(event: unknown, refs: Map): void { + const data = (event as { data?: unknown }).data + if (typeof data !== 'object' || data === null) return + const carrier = data as { + content?: unknown + message?: { content?: unknown } + inserted?: Array<{ content?: unknown }> + chunk?: { type?: unknown; block?: unknown } + } + collectImageRefs(carrier.content, refs) + if (carrier.message !== undefined) collectImageRefs(carrier.message.content, refs) + if (carrier.inserted !== undefined) { + for (const message of carrier.inserted) collectImageRefs(message.content, refs) + } + if (carrier.chunk?.type === 'block-end') collectImageRefs([carrier.chunk.block], refs) +} + +/** + * Collect the distinct media references one stored artifact text names. + * Lines that fail to parse cannot reference media and are skipped (the + * artifact text itself is exported verbatim regardless). + * @param content - the stored artifact text. + * @returns the dedupe map keyed by attachment id. + */ +function imageRefsInArtifact(content: string): Map { + const refs = new Map() + for (const line of content.split('\n')) { + if (line === '') continue + let event: unknown + try { + event = JSON.parse(line) + } catch { + continue + } + collectEventImageRefs(event, refs) + } + return refs +} + +/** + * One safe zip path segment from an untrusted session id. Session ids are + * host-controlled, but the brand allows any non-empty string, so `../`, dot + * segments, and separator characters are neutralized before they can shape + * archive entries. Distinct ids may collapse onto one segment (id collision + * is impossible for the host-minted UUIDs, so no uniqueness suffix is kept). + * @param id - the raw session id. + * @returns a filesystem-safe single path segment. + */ +function safeSessionIdSegment(id: string): string { + return id.replace(/[^A-Za-z0-9_-]/g, '_') +} + +/** + * The export archive filename for one root session. + * @param sessionId - the root session id (sanitized to one safe path segment). + * @returns the attachment filename for the session's export archive. + */ +export function sessionLogZipFilename(sessionId: string): string { + return `dsh-session-${safeSessionIdSegment(sessionId)}.zip` +} + +/** + * Yield the export entries in zip order: the preloaded root artifact first, + * then every subagent descendant in lineage order (each read from the + * persistence backend right before it is yielded and dropped after the + * consumer moves on), then every distinct media object referenced by any of + * the included logs (read and verified from the attachment store, one archive + * entry per attachment id). The host holds at most one descendant's artifact + * text and one media object at a time beyond the root. + * @param deps - the mounted export services (the caller answered 500 before this runs). + * @param root - the already-read root artifact (read by the caller so the + * missing-session path can answer cleanly before streaming starts). + * @param sessionId - the root session id. + * @param includeDescendants - whether to include every subagent descendant. + * @param signal - optional cancellation for read work. + * @returns the export entries in zip order. + */ +export async function* sessionLogZipEntries( + deps: SessionLogExportReady, + root: SessionRawArtifact, + sessionId: SessionId, + includeDescendants: boolean, + signal?: AbortSignal, +): AsyncGenerator { + const media = new Map() + const rememberMedia = (content: string): void => { + for (const [id, ref] of imageRefsInArtifact(content)) media.set(id, ref) + } + rememberMedia(root.content) + yield { path: root.filename, content: root.content } + if (includeDescendants) { + const seen = new Set([sessionId]) + const collect = async function* ( + nodes: readonly SessionLineageNode[], + ): AsyncGenerator { + for (const node of nodes) { + signal?.throwIfAborted() + const id = node.session.header.id + if (seen.has(id)) continue + seen.add(id) + const raw = await deps.sessionPersistence.readRaw(id) + if (raw === undefined) { + throw new Error(`subagent "${id}" has no stored log artifact`) + } + rememberMedia(raw.content) + yield { + path: `subagents/${safeSessionIdSegment(id)}/${raw.filename}`, + content: raw.content, + } + yield* collect(node.descendants) + } + } + const lineage = await deps.sessionQuery.traceSession(sessionId) + yield* collect(lineage.descendants) + } + for (const ref of media.values()) { + signal?.throwIfAborted() + const stored = await deps.attachments.readImage(ref) + yield { path: mediaEntryPath(ref), data: stored.data } + } +} + +/** How many code units of artifact text one zip push carries (bounded encode memory). */ +const PUSH_CHUNK_CODE_UNITS = 1 << 16 + +/** How many bytes of media one zip push carries (bounded memory; images are already size-capped). */ +const PUSH_CHUNK_BYTES = 1 << 16 + +/** + * Push one media object's bytes into a deflate stream in bounded chunks, + * yielding to a slow consumer between chunks like the artifact path does. + * @param deflate - the zip entry's deflate stream. + * @param data - the stored image bytes. + * @param signal - optional cancellation; throws when aborted. + */ +async function pushBinaryChunks( + deflate: ZipDeflate, + data: Uint8Array, + controller: ReadableStreamDefaultController, + signal?: AbortSignal, +): Promise { + let offset = 0 + do { + signal?.throwIfAborted() + const end = Math.min(offset + PUSH_CHUNK_BYTES, data.byteLength) + const finalChunk = end >= data.byteLength + deflate.push(data.subarray(offset, end), finalChunk) + offset = end + /* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */ + if (controller.desiredSize !== null && controller.desiredSize < 0) { + await new Promise(resolve => setTimeout(resolve, 0)) + } + } while (offset < data.byteLength) +} + +/** + * Push one artifact's text into a deflate stream in bounded chunks, never + * splitting a surrogate pair across a chunk boundary (a lone high surrogate + * re-encodes as U+FFFD and would silently corrupt the exported artifact). + * @param deflate - the zip entry's deflate stream. + * @param content - the artifact text verbatim. + * @param signal - optional cancellation; throws when aborted. + */ +async function pushArtifactChunks( + deflate: ZipDeflate, + content: string, + controller: ReadableStreamDefaultController, + signal?: AbortSignal, +): Promise { + const encoder = new TextEncoder() + let offset = 0 + let finalChunk: boolean + do { + signal?.throwIfAborted() + let end = Math.min(offset + PUSH_CHUNK_CODE_UNITS, content.length) + if (end < content.length && end - offset > 1) { + // Back off one code unit when the boundary lands inside a surrogate + // pair: the pair then starts the next chunk whole. + const last = content.charCodeAt(end - 1) + if (last >= 0xd800 && last <= 0xdbff) end -= 1 + } + finalChunk = end >= content.length + deflate.push(encoder.encode(content.slice(offset, end)), finalChunk) + offset = end + /* v8 ignore next 2 -- only fires when a slow consumer leaves the queue over-full */ + if (controller.desiredSize !== null && controller.desiredSize < 0) { + await new Promise(resolve => setTimeout(resolve, 0)) + } + } while (!finalChunk) +} + +/** + * Stream one session-log ZIP as a WHATWG ReadableStream. The root artifact is + * read and validated by the caller before this is called (missing root or + * missing services answer cleanly before any byte is produced); each entry is + * then encoded and deflated in bounded chunks as it is produced, so the + * archive bytes arrive incrementally. A descendant that fails to read errors + * the stream (fail-loud, never silent under-export). + * @param deps - the mounted export services (the caller answered 500 before this runs). + * @param root - the already-read root artifact (first zip entry). + * @param sessionId - the root session id. + * @param includeDescendants - whether to include every subagent descendant. + * @param signal - optional cancellation for read work. + * @returns the zip byte stream. + */ +export function streamSessionLogZip( + deps: SessionLogExportReady, + root: SessionRawArtifact, + sessionId: SessionId, + includeDescendants: boolean, + signal?: AbortSignal, +): ReadableStream { + return new ReadableStream({ + start(controller) { + // fflate invokes the callback synchronously per compressed chunk, so a + // single push can enqueue ahead of a slow consumer; pushArtifactChunks + // yields between chunks once the queue is over-full, bounding the + // accumulation to the queue high-water mark plus one push. + const zip = new Zip((error, data, final) => { + /* v8 ignore next 3 -- fflate reports only internal zip failures, unreachable for valid inputs */ + if (error) { + controller.error(error) + return + } + /* v8 ignore next -- fflate may emit empty chunks; not controllable from tests */ + if (data.byteLength > 0) controller.enqueue(data) + if (final) controller.close() + }) + void (async () => { + try { + for await (const entry of sessionLogZipEntries(deps, root, sessionId, includeDescendants, signal)) { + const deflate = new ZipDeflate(entry.path, { level: 6 }) + zip.add(deflate) + if ('content' in entry) { + await pushArtifactChunks(deflate, entry.content, controller, signal) + } else { + await pushBinaryChunks(deflate, entry.data, controller, signal) + } + } + zip.end() + } catch (error) { + // A mid-stream failure (missing descendant, cancellation, read + // error) must fail the download rather than ship a truncated archive. + /* v8 ignore next -- typed backends reject with Error, and DOMException is one in Node */ + controller.error(error instanceof Error ? error : new Error(String(error))) + } + })() + }, + }) +} diff --git a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts index 106cb213da..34eb306913 100644 --- a/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-agent-preset.spec.ts @@ -8,7 +8,7 @@ import { mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type AgentFactory } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' @@ -354,11 +354,11 @@ describe('agentPreset.select', () => { }) it('records the switch in the log, and the list reads it back', async () => { - const { api, ctx } = await harness(['standard', 'core-web']) + const { api, ctx } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('sel-log'), agentPreset: 'standard' })) await api.agentPresets.select( - request({ sessionId: SessionId('sel-log'), agentPreset: 'core-web' })) + request({ sessionId: SessionId('sel-log'), agentPreset: 'minimal' })) // The header is written once at creation, so the switch lives in the log — // this is what a restart replays and what every projection resolves from. @@ -366,11 +366,11 @@ describe('agentPreset.select', () => { const session = ctx.sessions.get(SessionId('sel-log')) if (session === undefined) throw new Error('unreachable') expect(session.header.agentPreset).toBe('standard') - expect(resolveSessionPreset(session)).toBe('core-web') + expect(resolveSessionPreset(session)).toBe('minimal') const listed = await api.sessions.list(request({})) if (!listed.result.ok) throw new Error('unreachable') expect(listed.result.value.items.find(item => item.sessionId === 'sel-log')?.agentPreset) - .toBe('core-web') + .toBe('minimal') }) it('frames the committed switch so clients can drop that session\'s catalogs', async () => { @@ -405,14 +405,14 @@ describe('agentPreset.select', () => { }) it('serializes two concurrent selects on one session', async () => { - const { api, ctx } = await harness(['standard', 'core-web']) + const { api, ctx } = await harness(['standard', 'minimal']) await api.sessions.create(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })) // Both pass the blank check; unserialized, the second unmount finds no // record because the first already removed it, and two compositions end up // in one agent layer. The client's busy flag is not enforcement. const [first, second] = await Promise.all([ - api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'core-web' })), + api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'minimal' })), api.agentPresets.select(request({ sessionId: SessionId('sel-race'), agentPreset: 'standard' })), ]) @@ -635,7 +635,7 @@ describe('skills over the layered host registry', () => { }) it('resolves a cold session to its recorded preset standing key', async () => { - const { api, ctx } = await harness(['standard', 'core-web']) + const { api, ctx } = await harness(['standard', 'minimal']) const seen: unknown[] = [] ctx.provide('skills', { list: (options: { scope?: unknown }) => { @@ -643,12 +643,12 @@ describe('skills over the layered host registry', () => { return Promise.resolve([]) }, } as never) - ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'core-web' } }) + ctx.sessions.create(SessionId('h2'), { meta: { cwd: '/workspace/cold', agentPreset: 'minimal' } }) const response = await api.skills.list(request({ sessionId: SessionId('h2') })) expect(response.result).toMatchObject({ ok: true, value: { skills: [] } }) - expect(seen).toEqual([standingKeys.get('core-web')]) + expect(seen).toEqual([standingKeys.get('minimal')]) }) it('serves the global view when the roster no longer supplies the recorded preset', async () => { @@ -671,8 +671,8 @@ describe('skills over the layered host registry', () => { describe('session.history presenter scope', () => { it('asks the roster for the RECORDED preset\'s standing key on a cold read', async () => { - const { api } = await harness(['standard', 'core-web']) - await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'core-web' })) + const { api } = await harness(['standard', 'minimal']) + await api.sessions.create(request({ sessionId: SessionId('p1'), agentPreset: 'minimal' })) // Cold: creation registered a live agent in this harness, so simulate the // cold path by asking for a session only persistence knows... the harness // has no persistence, so read the live one and assert no roster query. diff --git a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts index 1cee4cd4e0..8aaa44564b 100644 --- a/packages/host/apiproxy/tests/api-proxy-approval.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-approval.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts index e01282043b..066c47f376 100644 --- a/packages/host/apiproxy/tests/api-proxy-blank.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-blank.spec.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts index 78c748861a..126ce506b8 100644 --- a/packages/host/apiproxy/tests/api-proxy-cold.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-cold.spec.ts @@ -8,7 +8,7 @@ import { mkdtempSync, writeFileSync, utimesSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import { TypeRTLookupFailure } from '@deepseek-ai/dsh-type-meta' diff --git a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts index 5f0af7cc5c..038c1050f7 100644 --- a/packages/host/apiproxy/tests/api-proxy-commands.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-commands.spec.ts @@ -10,7 +10,7 @@ import { MessageId, freezeMessage } from '@deepseek-ai/dsh-llm' */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/host/apiproxy/tests/api-proxy-config.spec.ts b/packages/host/apiproxy/tests/api-proxy-config.spec.ts index b8b067cae3..286a6d3f4f 100644 --- a/packages/host/apiproxy/tests/api-proxy-config.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-config.spec.ts @@ -6,8 +6,8 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import AgentRegistry from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts index d1bf611fbb..e1d4dc5aa4 100644 --- a/packages/host/apiproxy/tests/api-proxy-fork.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-fork.spec.ts @@ -1,7 +1,7 @@ /** Session-fork boundaries, lineage, and inherited model routing. */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' import { createUserMessage, ReasoningEffortId } from '@deepseek-ai/dsh-llm' diff --git a/packages/host/apiproxy/tests/api-proxy-models.spec.ts b/packages/host/apiproxy/tests/api-proxy-models.spec.ts index bdd21128b4..335b8b795f 100644 --- a/packages/host/apiproxy/tests/api-proxy-models.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-models.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { agentEvents } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import LlmService, { LlmAdapter, ReasoningEffortId } from '@deepseek-ai/dsh-llm' diff --git a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts index 45335e131b..a3a8867291 100644 --- a/packages/host/apiproxy/tests/api-proxy-projections.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-projections.spec.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/host/apiproxy/tests/api-proxy-question.spec.ts b/packages/host/apiproxy/tests/api-proxy-question.spec.ts index 2ca7a31d12..fed6a5435e 100644 --- a/packages/host/apiproxy/tests/api-proxy-question.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-question.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' diff --git a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts index 346ed9f313..bf2a7e3ba0 100644 --- a/packages/host/apiproxy/tests/api-proxy-rename.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-rename.spec.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent, AgentHandle, CreateAgentOptions } from '@deepseek-ai/dsh-agent' diff --git a/packages/host/apiproxy/tests/api-proxy-search.spec.ts b/packages/host/apiproxy/tests/api-proxy-search.spec.ts index 7637399f50..c945020eb1 100644 --- a/packages/host/apiproxy/tests/api-proxy-search.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-search.spec.ts @@ -5,7 +5,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { stat } from 'node:fs/promises' import AgentRegistry from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts index eb39bb6791..02f194eede 100644 --- a/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-subagents.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import { SubagentError } from '@deepseek-ai/dsh-subagent' import { RpcId } from '../src/api/rpc.ts' diff --git a/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts new file mode 100644 index 0000000000..0bb7aa0333 --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts @@ -0,0 +1,263 @@ +/** + * Background-task carrier paths of the host ApiProxy: the subscription + * baseline is sent only for a session that has tasks, every registry change + * pushes that owner's whole set, an unowned change fans out to every + * subscribed session, the projection drops the three internal snapshot + * fields, a composition without `ctx.tasks` emits nothing, and listing never + * resumes a cold session. + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' +import type { Agent } from '@deepseek-ai/dsh-agent' +import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' +import type { Session } from '@deepseek-ai/dsh-session' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import LocalTaskService from '@deepseek-ai/dsh-tasks-local' +import type { TaskOutcome } from '@deepseek-ai/dsh-tasks' +import type { MuxFrame, RpcRequest } from '@deepseek-ai/dsh-host-apiproxy/api' +import { RpcId } from '@deepseek-ai/dsh-host-apiproxy/api/rpc' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' + +type TaskFrame = Extract + +/** + * A producer whose settlement the test drives. `cancel` deliberately does not + * settle, so a kill is observable as the distinct `stopping` step before the + * test supplies the terminal outcome and its detail. + */ +function producer(label = 'sleep 60') { + let settle!: (outcome: TaskOutcome) => void + // A stream producer, so the carrier CAN consume the cursor if it ever calls + // `read()`; `reads` is what proves it never does. + const reads = { count: 0 } + const spec = { + kind: 'bash' as const, + label, + run: () => ({ + cancel: () => {}, + done: new Promise((resolve) => { settle = resolve }), + readOutput: () => { reads.count += 1; return 'stolen output' }, + }), + } + return { spec, reads, settle: (outcome: TaskOutcome) => { settle(outcome) } } +} + +async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(UserInteractionService) + await ctx.plugin(AgentRegistry) + if (withRegistry) { + await ctx.plugin(LocalTaskService) + ctx.tasks.attachSurface('api-proxy-test') + } + const session = ctx.sessions.create() + const agent = { + id: session.id, + session, + inbox: new Inbox(session, { inserted: () => {}, discarded: () => {}, claimed: () => {} }), + status: 'idle', + ctx, + } as Agent + ctx.agents.register(agent) + return { ctx, session, agent } +} + +const api = (ctx: Context) => createApiProxy(ctx, { defaultModelSelection: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp' }) + +/** Drain the mux until `count` session/tasks frames arrived, then abort. */ +async function collect( + iterable: AsyncIterable>, + count: number, + abort: AbortController, +): Promise { + const frames: MuxFrame[] = [] + for await (const envelope of iterable) { + frames.push(envelope.payload) + if (frames.filter(frame => frame.type === 'session/tasks').length >= count) abort.abort() + } + return frames.filter((frame): frame is TaskFrame => frame.type === 'session/tasks') +} + +describe('session/tasks subscription baseline', () => { + it('is omitted for a session with no tasks — absence is the empty set', async () => { + const { ctx, session } = await harness(true) + const abort = new AbortController() + const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-empty'), payload: {} }, abort.signal) + const frames: MuxFrame[] = [] + const drained = (async () => { + for await (const envelope of stream) { + frames.push(envelope.payload) + if (frames.some(frame => frame.type === 'session/subscribed')) abort.abort() + } + })() + await drained + expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false) + expect(frames.some(frame => frame.type === 'session/subscribed')).toBe(true) + void session + }) + + it('carries the live set for a session that already has tasks when the stream opens', async () => { + const { ctx, session, agent } = await harness(true) + ctx.tasks.start({ ...producer('pnpm run build').spec, owner: agent }) + const abort = new AbortController() + const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-baseline'), payload: {} }, abort.signal) + const [baseline] = await collect(stream, 1, abort) + expect(baseline?.sessionId).toBe(session.id) + expect(baseline?.tasks).toHaveLength(1) + const [task] = baseline?.tasks ?? [] + expect(task?.startedAt).toBeTypeOf('number') + expect({ ...task, startedAt: 0 }).toEqual({ + id: 'bash-1', + kind: 'bash', + label: 'pnpm run build', + status: 'running', + startedAt: 0, + }) + }) +}) + +describe('session/tasks change pushes', () => { + it('pushes the owner\'s whole set on registration, stopping, and settlement', async () => { + const { ctx, session, agent } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-changes'), payload: {} }, abort.signal) + const collected = collect(stream, 3, abort) + + const p = producer() + const id = ctx.tasks.start({ ...p.spec, owner: agent }) + ctx.tasks.kill(id, agent, 'test') + p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) + + const frames = await collected + expect(frames.map(frame => frame.sessionId)).toEqual([session.id, session.id, session.id]) + expect(frames.map(frame => frame.tasks[0]?.status)).toEqual(['running', 'stopping', 'killed']) + // Terminal detail rides the same whole-set push; no separate signal. + expect(frames[2]?.tasks[0]?.detail).toBe('signal: SIGTERM') + expect(frames[2]?.tasks[0]?.finishedAt).toBeTypeOf('number') + }) + + it('drops ownerSession, reported, and outputLimitBytes from the wire view', async () => { + const { ctx, agent } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-fields'), payload: {} }, abort.signal) + const collected = collect(stream, 1, abort) + ctx.tasks.start({ ...producer().spec, owner: agent, outputLimitBytes: 1_024 }) + + const [frame] = await collected + const fields: readonly string[] = Object.keys(frame?.tasks[0] ?? {}) + expect([...fields].sort()).toEqual(['id', 'kind', 'label', 'startedAt', 'status']) + }) + + it('fans an unowned change out to every subscribed session', async () => { + const { ctx } = await harness(true) + const second = ctx.sessions.create() + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-unowned'), payload: {} }, abort.signal) + const collected = collect(stream, 2, abort) + + ctx.tasks.start(producer('open to every caller').spec) + + const frames = await collected + expect(new Set(frames.map(frame => frame.sessionId)).size).toBe(2) + expect(frames.some(frame => frame.sessionId === second.id)).toBe(true) + for (const frame of frames) expect(frame.tasks[0]?.label).toBe('open to every caller') + }) + + it('serves a cold session the unowned set without resuming it', async () => { + const { ctx } = await harness(true) + const coldId = SessionId('session-cold-tasks') + let loaded = false + ctx.provide('sessionPersistence', { + list: async () => [{ version: 0, id: coldId, createdAt: 5, cwd: '/tmp' }], + locate: () => undefined, + load: () => { loaded = true; throw new Error('task listing must not load a cold log') }, + } as never) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-cold'), payload: {} }, abort.signal) + const collected = collect(stream, 1, abort) + + ctx.tasks.start(producer().spec) + await collected + expect(loaded).toBe(false) + expect(ctx.agents.get(coldId)).toBeUndefined() + }) +}) + +describe('session/tasks without the registry', () => { + it('emits no frames at all, so the client renders no entry point', async () => { + const { ctx, session } = await harness(false) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-absent'), payload: {} }, abort.signal) + const frames: MuxFrame[] = [] + const drained = (async () => { + for await (const envelope of stream) { + frames.push(envelope.payload) + if (frames.filter(frame => frame.type === 'session/event').length >= 1) abort.abort() + } + })() + session.append('turn/start', { turn: 1 }) + await drained + expect(frames.some(frame => frame.type === 'session/tasks')).toBe(false) + }) +}) + +describe('session/tasks never consumes model output', () => { + it('drives the whole lifecycle without calling the single consuming cursor', async () => { + // `ctx.tasks.read()` consumes the one output cursor, so a carrier read + // silently takes bytes the model's `task_output` will never see. The + // failure is invisible at the call site, which is why this asserts the + // count rather than trusting review. + const { ctx, agent } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-no-read'), payload: {} }, abort.signal) + const collected = collect(stream, 3, abort) + + const p = producer() + const id = ctx.tasks.start({ ...p.spec, owner: agent }) + ctx.tasks.kill(id, agent, 'test') + p.settle({ status: 'killed', detail: 'signal: SIGTERM' }) + await collected + + expect(p.reads.count).toBe(0) + }) + + it('reads nothing while minting the subscription baseline either', async () => { + const { ctx, agent } = await harness(true) + const p = producer() + ctx.tasks.start({ ...p.spec, owner: agent }) + + const abort = new AbortController() + const stream = api(ctx).events.mux({ rpcId: RpcId('t-tasks-no-read-baseline'), payload: {} }, abort.signal) + const [baseline] = await collect(stream, 1, abort) + + expect(baseline?.tasks).toHaveLength(1) + expect(p.reads.count).toBe(0) + }) +}) + +describe('session/tasks baseline for a session born after the stream opened', () => { + it('carries the already-visible unowned set to the new session', async () => { + const { ctx } = await harness(true) + const proxy = api(ctx) + const abort = new AbortController() + const stream = proxy.events.mux({ rpcId: RpcId('t-tasks-late-session'), payload: {} }, abort.signal) + + // One unowned task exists before the new session is created; the subscribe + // frame clears the client mirror, so the baseline has to follow it. + ctx.tasks.start(producer('visible to every caller').spec) + const created = ctx.sessions.create() + + const frames = await collect(stream, 2, abort) + const forNew = frames.filter(frame => frame.sessionId === created.id) + expect(forNew.at(-1)?.tasks[0]?.label).toBe('visible to every caller') + }) +}) diff --git a/packages/host/apiproxy/tests/api-proxy-view.spec.ts b/packages/host/apiproxy/tests/api-proxy-view.spec.ts index 86fb593f4b..eabac2056b 100644 --- a/packages/host/apiproxy/tests/api-proxy-view.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-view.spec.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts index ddb53cf02f..a8f12641da 100644 --- a/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-workspace.spec.ts @@ -2,7 +2,7 @@ import { existsSync, mkdirSync, mkdtempSync, realpathSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent, AgentFactory } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/host/apiproxy/tests/client-handler.spec.ts b/packages/host/apiproxy/tests/client-handler.spec.ts index 2b9249b7f0..9365006de4 100644 --- a/packages/host/apiproxy/tests/client-handler.spec.ts +++ b/packages/host/apiproxy/tests/client-handler.spec.ts @@ -133,6 +133,7 @@ function scriptedApi(overrides: { }, events: { mux: () => empty(), host: () => empty(), ...overrides.events }, respond: overrides.respond ?? (() => Promise.resolve({ accepted: false as const, reason: 'not-pending' as const })), + downloads: { sessionLog: async () => new Response('stub', { status: 404 }) }, } } diff --git a/packages/host/apiproxy/tests/fetch-carrier.spec.ts b/packages/host/apiproxy/tests/fetch-carrier.spec.ts index 63c40414d8..ed286334a8 100644 --- a/packages/host/apiproxy/tests/fetch-carrier.spec.ts +++ b/packages/host/apiproxy/tests/fetch-carrier.spec.ts @@ -300,6 +300,11 @@ function fakeApi(overrides: Partial<{ muxFrames: MuxFrame[]; hostFrames: HostFra async respond(message: ClientResponse): Promise { return message.rpcId === 'known' ? { accepted: true } : { accepted: false, reason: 'not-pending' } }, + downloads: { + async sessionLog() { + return new Response('stub', { status: 404 }) + }, + }, } } diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 3be4bf5715..789639b70d 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -436,6 +436,11 @@ describe('events frame schemas', () => { }, ] }, { type: 'session/projection', sessionId: 's', key: 'todos', value: [{ content: 'x', status: 'pending' }], seq: 7 }, + { type: 'session/tasks', sessionId: 's', tasks: [] }, + { type: 'session/tasks', sessionId: 's', tasks: [ + { id: 'bash-1', kind: 'bash', label: 'pnpm run build', status: 'running', startedAt: 5 }, + { id: 'pty-send-2', kind: 'pty-send', label: 'send keys', status: 'failed', detail: 'exit code: 3', startedAt: 5, finishedAt: 9 }, + ] }, { type: 'stream/error', error: { code: 'internal', message: 'm', details: {} } }, ] for (const frame of frames) expect(muxFrameSchema.parse(frame)).toMatchObject({ type: frame.type }) @@ -444,6 +449,14 @@ describe('events frame schemas', () => { { type: 'session/projection', sessionId: 's', key: '', value: null, seq: 0 }, { type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: -1 }, { type: 'session/projection', sessionId: 's', key: 'todos', value: null, seq: 0.5 }, + // A producer kind stays an open string, but the closed status set and + // the identity/label bounds are the carrier's own wire contract. + { type: 'session/tasks', sessionId: 's', tasks: [{ id: '', kind: 'bash', label: 'l', status: 'running', startedAt: 0 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: '', label: 'l', status: 'running', startedAt: 0 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: '', status: 'running', startedAt: 0 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'pending', startedAt: 0 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'running', startedAt: -1 }] }, + { type: 'session/tasks', sessionId: 's', tasks: [{ id: 'bash-1', kind: 'bash', label: 'l', status: 'completed', startedAt: 0, finishedAt: 0.5 }] }, ]) expect(() => muxFrameSchema.parse(invalid)).toThrow() expect(askUserQuestionItemSchema.parse({ id: 'q', question: 'Q?' }).id).toBe('q') }) diff --git a/packages/host/apiproxy/tests/session-export.spec.ts b/packages/host/apiproxy/tests/session-export.spec.ts new file mode 100644 index 0000000000..923e70b380 --- /dev/null +++ b/packages/host/apiproxy/tests/session-export.spec.ts @@ -0,0 +1,377 @@ +/** + * session.export host path: the GET download endpoint streams a ZIP whose + * files are the stored artifacts verbatim (root + optional descendants), and + * the degenerate compositions fail loudly (missing services → 500, missing + * root → 404, missing descendant → errored stream). + */ + +import { describe, expect, it } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import { unzipSync, strFromU8 } from 'fflate' +import type { ImageAttachmentRef } from '@deepseek-ai/dsh-attachment' +import UserInteractionService from '@deepseek-ai/dsh-user-interaction' +import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' +import type { SessionLineageNode } from '@deepseek-ai/dsh-session-query' +import type { SessionRawArtifact } from '@deepseek-ai/dsh-session-persistence' +import { toFetchHandler } from '@deepseek-ai/dsh-host-apiproxy' +import { createApiProxy } from '@deepseek-ai/dsh-host-apiproxy' + +const sid = (id: string): SessionId => id as SessionId + +function header(id: string, parentSession?: SessionId): SessionHeader { + return { + version: 0, + id: sid(id), + createdAt: 1000, + cwd: '/proj', + ...parentSession === undefined ? {} : { parentSession }, + delegationDepth: parentSession === undefined ? 0 : 1, + } +} + +function artifact(id: string, parentSession?: SessionId, content?: string): SessionRawArtifact { + return { + meta: header(id, parentSession), + filename: 'session.jsonl', + content: content ?? `{"type":"session","version":0,"id":"${id}","createdAt":1000}\n{"type":"turn/start","seq":0,"time":2000,"data":{"turn":1}}\n`, + } +} + +function node(id: string, ...descendants: SessionLineageNode[]): SessionLineageNode { + return { session: { header: header(id, sid('session-root')), live: false, persisted: true }, descendants } +} + +/** One durable image object served by the fake attachment store. */ +function storedImage(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png') { + return { + ref: { attachmentId: sid(id), mediaType, bytes: 4, width: 2, height: 2 } as unknown as ImageAttachmentRef, + data: new Uint8Array([1, 2, 3, 4]), + } +} + +/** A user/message event line carrying one image reference. */ +function imageEventLine(id: string, mediaType: ImageAttachmentRef['mediaType'] = 'image/png'): string { + return `{"type":"user/message","seq":1,"time":1000,"data":{"content":[{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}]}}` +} + +async function buildApi( + artifacts: Record, + descendants: SessionLineageNode[] = [], + services: { + query?: boolean + persistence?: boolean | 'throw' + attachments?: boolean | ((ref: ImageAttachmentRef) => Promise>) + } = {}, +) { + const ctx = new Context() + await ctx.plugin(UserInteractionService) + const query = services.query ?? true + const persistence = services.persistence ?? true + if (query) { + ctx.provide('sessionQuery', { + traceSession: async () => ({ + target: { header: header('session-root'), live: false, persisted: true }, + ancestors: [], + complete: true, + root: { header: header('session-root'), live: false, persisted: true }, + descendants, + }), + } as never) + } + if (persistence) { + ctx.provide('sessionPersistence', { + readRaw: async (id: SessionId) => { + if (persistence === 'throw') throw new Error('/host/private/session.jsonl') + return artifacts[id] + }, + } as never) + } + if (services.attachments !== false) { + const readImage = typeof services.attachments === 'function' + ? services.attachments + : async (ref: ImageAttachmentRef) => storedImage(String(ref.attachmentId), ref.mediaType) + ctx.provide('attachments', { + imageLimits: {} as never, + validateImage: async () => {}, + saveImage: async () => { throw new Error('export never saves images') }, + readImage, + } as never) + } + return createApiProxy(ctx, { + defaultModelSelection: () => ({ provider: 'p', model: 'm' }), + cwd: '/tmp', + }) +} + +async function responseBytes(response: Response): Promise { + return new Uint8Array(await response.arrayBuffer()) +} + +describe('session.export download endpoint', () => { + it('streams a ZIP with the root artifact verbatim under its original filename', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + expect(response.headers.get('content-type')).toBe('application/zip') + expect(response.headers.get('content-disposition')).toContain('dsh-session-session-root.zip') + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(artifact('session-root').content) + }) + + it('includes descendant artifacts under subagents// when requested', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + 'child-a': artifact('child-a', sid('session-root')), + 'grandchild-a': artifact('grandchild-a', sid('child-a')), + }, [ + node('child-a', node('grandchild-a')), + ]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(response.status).toBe(200) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'session.jsonl', + 'subagents/child-a/session.jsonl', + 'subagents/grandchild-a/session.jsonl', + ]) + expect(strFromU8(files['subagents/child-a/session.jsonl'] as Uint8Array)) + .toBe(artifact('child-a').content) + }) + + it('answers 404 for a missing root session', async () => { + const api = await buildApi({}) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(404) + }) + + it('answers 400 when the sessionId query parameter is absent', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?includeDescendants=true'), + ) + expect(response.status).toBe(400) + }) + + it('answers 400 for an includeDescendants value other than true or false', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=1'), + ) + expect(response.status).toBe(400) + }) + + it('answers 500 when the deployment mounts no persistence or session-query service', async () => { + const api = await buildApi({}, [], { query: false, persistence: false }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + expect(await response.text()).toContain('session-query') + }) + + it('fails the whole export when a descendant has no stored artifact', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + }, [node('child-missing')]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(response.status).toBe(200) + // The stream errors before completing, so the body read rejects rather + // than returning a truncated-but-valid archive. + await expect(response.arrayBuffer()).rejects.toThrow() + }) + + it('keeps an astral character whole when its surrogate pair straddles a push boundary', async () => { + // The push loop slices by 2^16 code units and must back off one unit when + // the boundary lands inside a surrogate pair; otherwise the pair re-encodes + // as U+FFFD and the exported artifact is silently corrupted. + const root = { ...artifact('session-root'), content: `${'a'.repeat((1 << 16) - 1)}😀tail` } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('splits a long artifact on a plain code-unit boundary without backoff', async () => { + // A boundary that lands on a BMP character needs no surrogate backoff; the + // round trip must still be byte-identical across the multi-chunk push. + const root = { ...artifact('session-root'), content: 'z'.repeat((1 << 16) + 4096) } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe(root.content) + }) + + it('exports an empty artifact as an empty zip entry', async () => { + const root = { ...artifact('session-root'), content: '' } + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files)).toEqual(['session.jsonl']) + expect(strFromU8(files['session.jsonl'] as Uint8Array)).toBe('') + }) + + it('exports a shared lineage node once (seen-set dedup)', async () => { + const api = await buildApi({ + 'session-root': artifact('session-root'), + 'child-a': artifact('child-a', sid('session-root')), + 'child-b': artifact('child-b', sid('session-root')), + shared: artifact('shared', sid('child-a')), + }, [ + node('child-a', node('shared')), + node('child-b', node('shared')), + ]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'session.jsonl', + 'subagents/child-a/session.jsonl', + 'subagents/child-b/session.jsonl', + 'subagents/shared/session.jsonl', + ]) + }) + + it('answers 500 without leaking the backend error when the root artifact read fails', async () => { + const api = await buildApi({}, [], { query: true, persistence: 'throw' }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + const body = await response.text() + expect(body).toBe('session log export failed to read the stored artifact') + expect(body).not.toContain('/host/private/') + }) + + it('includes media objects referenced by the root log under media/.', async () => { + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + imageEventLine('img-1'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual(['media/img-1.png', 'session.jsonl']) + expect(files['media/img-1.png']).toEqual(storedImage('img-1').data) + }) + + it('collects media referenced from nested tool results', async () => { + const nested = '{"type":"assistant/message","seq":2,"time":2000,"data":{"content":[{"type":"tool-result","content":[{"type":"image","attachment":{"attachmentId":"nested-1","mediaType":"image/webp","bytes":4,"width":2,"height":2}}]}]}}' + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + nested, + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual(['media/nested-1.webp', 'session.jsonl']) + }) + + it('scans the wrapped, inserted, and chunk carriers plus non-object content items', async () => { + const block = (id: string, mediaType: string) => + `{"type":"image","attachment":{"attachmentId":"${id}","mediaType":"${mediaType}","bytes":4,"width":2,"height":2}}` + const wrapped = `{"type":"assistant/message","seq":2,"time":2000,"data":{"message":{"role":"assistant","content":["noise",${block('wrapped-1', 'image/jpeg')}]}}}` + const inserted = `{"type":"context/inserted","seq":3,"time":3000,"data":{"inserted":[{"content":[${block('inserted-1', 'image/gif')}]}]}}` + const chunk = `{"type":"assistant/chunk","seq":4,"time":4000,"data":{"chunk":{"type":"block-end","block":${block('chunk-1', 'image/png')}}}}` + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + wrapped, + inserted, + chunk, + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + const files = unzipSync(await responseBytes(response)) + expect(Object.keys(files).sort()).toEqual([ + 'media/chunk-1.png', + 'media/inserted-1.gif', + 'media/wrapped-1.jpg', + 'session.jsonl', + ]) + }) + + it('deduplicates one media object referenced by several included logs', async () => { + const line = imageEventLine('shared-img') + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + line, + ].join('\n') + '\n') + const child = artifact('child-a', sid('session-root'), [ + '{"type":"session","version":0,"id":"child-a","createdAt":1000}', + line, + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root, 'child-a': child }, [node('child-a')]) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + const files = unzipSync(await responseBytes(response)) + expect(files['media/shared-img.png']).toEqual(storedImage('shared-img').data) + expect(Object.keys(files).filter(name => name.startsWith('media/'))).toEqual(['media/shared-img.png']) + }) + + it('includes descendant media only when descendants are requested', async () => { + const child = artifact('child-a', sid('session-root'), [ + '{"type":"session","version":0,"id":"child-a","createdAt":1000}', + imageEventLine('child-img'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': artifact('session-root'), 'child-a': child }, [node('child-a')]) + const without = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(Object.keys(unzipSync(await responseBytes(without)))).toEqual(['session.jsonl']) + const withDescendants = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root&includeDescendants=true'), + ) + expect(Object.keys(unzipSync(await responseBytes(withDescendants))).sort()).toEqual([ + 'media/child-img.png', + 'session.jsonl', + 'subagents/child-a/session.jsonl', + ]) + }) + + it('fails the whole export when a referenced image cannot be read', async () => { + const root = artifact('session-root', undefined, [ + '{"type":"session","version":0,"id":"session-root","createdAt":1000}', + imageEventLine('gone-img'), + ].join('\n') + '\n') + const api = await buildApi({ 'session-root': root }, [], { + attachments: async () => { throw new Error('attachment bytes missing') }, + }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(200) + await expect(response.arrayBuffer()).rejects.toThrow('attachment bytes missing') + }) + + it('answers 500 when the deployment mounts no attachments service', async () => { + const api = await buildApi({ 'session-root': artifact('session-root') }, [], { attachments: false }) + const response = await toFetchHandler(api).fetch( + new Request('http://host/api/session.export?sessionId=session-root'), + ) + expect(response.status).toBe(500) + expect(await response.text()).toContain('attachments') + }) +}) diff --git a/packages/host/apiproxy/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 42b60ce906..2f4622185c 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -74,6 +74,9 @@ { "path": "../../skill/skill" }, + { + "path": "../../tasks/tasks" + }, { "path": "../../interaction/commands" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index 753f83845a..4ad110d48f 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-auto", "description": "Adaptive chooser of the directory-picker seam: resolves the host situation at boot and mounts the native or browse backend for the DeepSeek Harness web GUI host", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/directory-picker-auto" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,21 +32,21 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-host-directory-picker-browse": "^0.0.1", - "@deepseek-ai/dsh-host-directory-picker-native": "^0.0.1", - "@deepseek-ai/dsh-host-webserver": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", + "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-browse": "workspace:^", "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/directory-picker-auto/src/index.ts b/packages/host/directory-picker-auto/src/index.ts index 3cf75b20dc..91343463fd 100644 --- a/packages/host/directory-picker-auto/src/index.ts +++ b/packages/host/directory-picker-auto/src/index.ts @@ -10,9 +10,9 @@ * @module @deepseek-ai/dsh-host-directory-picker-auto */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' // Empty type imports carry the `loader` and `httpServer` Context merges for the reads below. -import type {} from '@cordisjs/plugin-loader' +import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-host-webserver' import { canExecute, hasLinuxChooserBinary } from './probe.ts' import type { DirectoryPickerBackendKind } from './resolve.ts' diff --git a/packages/host/directory-picker-auto/src/invariant.ts b/packages/host/directory-picker-auto/src/invariant.ts index 8b3f251447..c31102a752 100644 --- a/packages/host/directory-picker-auto/src/invariant.ts +++ b/packages/host/directory-picker-auto/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-host-directory-picker-auto/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-auto' 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 2ab50d6e80..ed9bfd1e44 100644 --- a/packages/host/directory-picker-auto/tests/loader-composition.spec.ts +++ b/packages/host/directory-picker-auto/tests/loader-composition.spec.ts @@ -13,9 +13,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import HttpServer from '@deepseek-ai/dsh-host-webserver' import type { DirectoryPicker } from '@deepseek-ai/dsh-host-directory-picker' import BrowseDirectoryPicker from '@deepseek-ai/dsh-host-directory-picker-browse' diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 85256ef7dc..3b42032e16 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-browse", "description": "In-app browsing backend of the directory-picker seam (listing/creation primitives over the host filesystem)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/directory-picker-browse" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -32,16 +39,16 @@ "dependencies": { "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "clsx": "^2.0.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-primitives": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-client-ui-workspace": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-locale": "workspace:^", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -53,7 +60,7 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "dsh": { diff --git a/packages/host/directory-picker-browse/src/index.ts b/packages/host/directory-picker-browse/src/index.ts index e84f51b26a..adbe6d09ad 100644 --- a/packages/host/directory-picker-browse/src/index.ts +++ b/packages/host/directory-picker-browse/src/index.ts @@ -12,8 +12,8 @@ import { mkdir, opendir, stat } from 'node:fs/promises' import { homedir } from 'node:os' import { basename, dirname, join, posix, resolve, win32 } from 'node:path' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { DirectoryPicker, DirectoryPickerError, } from '@deepseek-ai/dsh-host-directory-picker' diff --git a/packages/host/directory-picker-browse/src/invariant.ts b/packages/host/directory-picker-browse/src/invariant.ts index ba4bfe7b13..7170de7809 100644 --- a/packages/host/directory-picker-browse/src/invariant.ts +++ b/packages/host/directory-picker-browse/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-host-directory-picker-browse/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-browse' diff --git a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx index e7a07fb63a..69ea819564 100644 --- a/packages/host/directory-picker-browse/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-browse/tests/client-flow.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/host/directory-picker-browse/tests/service.spec.ts b/packages/host/directory-picker-browse/tests/service.spec.ts index 002d42e516..a969845636 100644 --- a/packages/host/directory-picker-browse/tests/service.spec.ts +++ b/packages/host/directory-picker-browse/tests/service.spec.ts @@ -4,7 +4,7 @@ import { mkdir, mkdtemp, rm, symlink, writeFile } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { basename, join } from 'node:path' import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { DirectoryPickerError } from '@deepseek-ai/dsh-host-directory-picker' import type { DirectoryPickerBrowseCapability } from '@deepseek-ai/dsh-host-directory-picker' import BrowseDirectoryPicker, { boundedInsert, fullyQualified, raceAbort } from '../src/index.ts' diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index c8e4e4003b..01817a238e 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-host-directory-picker-native", "description": "Native-OS-chooser backend of the directory-picker seam for the DeepSeek Harness web GUI host", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/directory-picker-native" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -40,11 +47,11 @@ "koffi": "^3.1.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-runtime": "^0.0.1", - "@deepseek-ai/dsh-client-ui-slots": "^0.0.1", - "@deepseek-ai/dsh-client-ui-workspace": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-client-ui-slots": "workspace:^", + "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -53,7 +60,7 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "tsx": "^4.19.2" }, diff --git a/packages/host/directory-picker-native/src/invariant.ts b/packages/host/directory-picker-native/src/invariant.ts index 777acd57dd..41b77ddb1c 100644 --- a/packages/host/directory-picker-native/src/invariant.ts +++ b/packages/host/directory-picker-native/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-host-directory-picker-native/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker-native' diff --git a/packages/host/directory-picker-native/tests/client-flow.spec.tsx b/packages/host/directory-picker-native/tests/client-flow.spec.tsx index 34eb5e40b4..d501fdfce2 100644 --- a/packages/host/directory-picker-native/tests/client-flow.spec.tsx +++ b/packages/host/directory-picker-native/tests/client-flow.spec.tsx @@ -1,5 +1,5 @@ // @vitest-environment jsdom -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { act, cleanup, render } from '@testing-library/react' import { afterEach } from 'vitest' diff --git a/packages/host/directory-picker-native/tests/service.spec.ts b/packages/host/directory-picker-native/tests/service.spec.ts index 61b5adddaf..a05100f5cb 100644 --- a/packages/host/directory-picker-native/tests/service.spec.ts +++ b/packages/host/directory-picker-native/tests/service.spec.ts @@ -1,7 +1,7 @@ /** Registration/capability behavior of the native backend (the seam's cordis half). */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import NativeDirectoryPicker from '../src/index.ts' describe('NativeDirectoryPicker', () => { diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index c37b67a4d7..0540414965 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-host-directory-picker", "description": "Abstract workspace-directory picking seam (ctx.directoryPicker) for the DeepSeek Harness web GUI host", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/directory-picker" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/directory-picker/src/index.ts b/packages/host/directory-picker/src/index.ts index 4a0a831247..5e8eb64f87 100644 --- a/packages/host/directory-picker/src/index.ts +++ b/packages/host/directory-picker/src/index.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-host-directory-picker */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' /** The native interaction: one OS directory chooser on the host display. */ export interface DirectoryPickerNativeCapability { @@ -115,7 +115,7 @@ export class DirectoryPickerError extends Error { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { directoryPicker: DirectoryPicker } diff --git a/packages/host/directory-picker/src/invariant.ts b/packages/host/directory-picker/src/invariant.ts index 05638bd0cf..abf29856fc 100644 --- a/packages/host/directory-picker/src/invariant.ts +++ b/packages/host/directory-picker/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned invariant companion for the directory-picker seam. @module @deepseek-ai/dsh-host-directory-picker/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-host-directory-picker' diff --git a/packages/host/directory-picker/tests/seam.spec.ts b/packages/host/directory-picker/tests/seam.spec.ts index 52722b9b0d..6fd6b48fa9 100644 --- a/packages/host/directory-picker/tests/seam.spec.ts +++ b/packages/host/directory-picker/tests/seam.spec.ts @@ -1,7 +1,7 @@ /** Contract behavior the seam itself owns: registration identity and typed failures. */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { DirectoryPicker, DirectoryPickerError } from '../src/index.ts' import type { DirectoryPickerCapability } from '../src/index.ts' diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index ac690cee24..61c144abe9 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-frontend-static", "description": "SPA dist server for the Web shell: owns the webserver fallback seat, serving the built frontend with index-tap injection, traversal rejection, and SPA index fallback", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/frontend-static" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-host-webserver": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "dependencies": { - "schemastery": "^3.18.0" - }, - "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/frontend-static/src/index.ts b/packages/host/frontend-static/src/index.ts index 8bd5b829c1..d42cf59301 100644 --- a/packages/host/frontend-static/src/index.ts +++ b/packages/host/frontend-static/src/index.ts @@ -14,8 +14,8 @@ import type { ServerResponse } from 'node:http' import { readFile } from 'node:fs/promises' import { dirname, extname, join, normalize, resolve, sep } from 'node:path' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-host-webserver' /** Stable Cordis plugin name. */ diff --git a/packages/host/frontend-static/src/invariant.ts b/packages/host/frontend-static/src/invariant.ts index 551daccbc8..567ff852ec 100644 --- a/packages/host/frontend-static/src/invariant.ts +++ b/packages/host/frontend-static/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-frontend-static/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-frontend-static' diff --git a/packages/host/frontend-static/tests/frontend-static.spec.ts b/packages/host/frontend-static/tests/frontend-static.spec.ts index 4f5fa0d2c7..16d75f88cd 100644 --- a/packages/host/frontend-static/tests/frontend-static.spec.ts +++ b/packages/host/frontend-static/tests/frontend-static.spec.ts @@ -11,9 +11,9 @@ import { tmpdir } from 'node:os' import { join } 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 Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import HttpServer from '@deepseek-ai/dsh-host-webserver' import * as FrontendStatic from '../src/index.ts' diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 4db9433935..016ae8f253 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-host-webserver", "description": "Web route-registration plugin: HTTP and upgrade routes, index transform taps, and static dist fallback; knows no harness concepts", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/host/webserver" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,14 +32,14 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "cordis": "^4.0.0-rc.7", - "@deepseek-ai/dsh-invariants": "^0.0.1" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^" } } diff --git a/packages/host/webserver/src/index.ts b/packages/host/webserver/src/index.ts index 2ff04379e3..e370d12224 100644 --- a/packages/host/webserver/src/index.ts +++ b/packages/host/webserver/src/index.ts @@ -12,10 +12,10 @@ import { createServer } from 'node:http' import type { IncomingMessage, ServerResponse, Server } from 'node:http' import type { AddressInfo } from 'node:net' import type { Duplex } from 'node:stream' -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { httpServer: HttpServerService } diff --git a/packages/host/webserver/src/invariant.ts b/packages/host/webserver/src/invariant.ts index 7becf3543b..036fa03932 100644 --- a/packages/host/webserver/src/invariant.ts +++ b/packages/host/webserver/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-host-webserver' diff --git a/packages/host/webserver/tests/webserver.spec.ts b/packages/host/webserver/tests/webserver.spec.ts index d91284c87b..1a592cd432 100644 --- a/packages/host/webserver/tests/webserver.spec.ts +++ b/packages/host/webserver/tests/webserver.spec.ts @@ -12,9 +12,9 @@ import { tmpdir } from 'node:os' import { join } 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 Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import HttpServer from '../src/index.ts' let root: string | undefined diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index cd62942a5f..870a901b99 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UI surfaces", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/interaction/commands" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -34,12 +41,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -47,6 +54,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/commands/src/index.ts b/packages/interaction/commands/src/index.ts index 9f4110568e..6b13ec549a 100644 --- a/packages/interaction/commands/src/index.ts +++ b/packages/interaction/commands/src/index.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-commands */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { NamedEntries, ScopedLayers } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' @@ -119,7 +119,7 @@ class CommandLayer implements ScopeLayer { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { commands: CommandService } diff --git a/packages/interaction/commands/src/invariant.ts b/packages/interaction/commands/src/invariant.ts index 792733c199..ff5d60a7cb 100644 --- a/packages/interaction/commands/src/invariant.ts +++ b/packages/interaction/commands/src/invariant.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-commands/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/interaction/commands/tests/commands.spec.ts b/packages/interaction/commands/tests/commands.spec.ts index 54b4227d19..7b39db932a 100644 --- a/packages/interaction/commands/tests/commands.spec.ts +++ b/packages/interaction/commands/tests/commands.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createScope } from '@deepseek-ai/dsh-scope' import type { Scope } from '@deepseek-ai/dsh-scope' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/interaction/commands/tests/invariant.spec.ts b/packages/interaction/commands/tests/invariant.spec.ts index 556be701e8..60bbe20696 100644 --- a/packages/interaction/commands/tests/invariant.spec.ts +++ b/packages/interaction/commands/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as CommandInvariant from '@deepseek-ai/dsh-commands/invariant' import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' diff --git a/packages/interaction/permission/package.json b/packages/interaction/permission/package.json index 0ae48d6e2f..054773f691 100644 --- a/packages/interaction/permission/package.json +++ b/packages/interaction/permission/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-permission", "description": "User-facing permission presets (ctx.permission) for the DeepSeek Harness: one product-level Permissions select bundling the sandbox-mode and approval-policy knobs, written through to their own session events", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/interaction/permission" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -34,19 +41,19 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-bash": "^0.0.1", - "@deepseek-ai/dsh-commands": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-projection": "^0.0.1", - "@deepseek-ai/dsh-settings": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-bash": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { @@ -59,6 +66,6 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/permission/src/index.ts b/packages/interaction/permission/src/index.ts index 35b762b2aa..a68431aeb5 100644 --- a/packages/interaction/permission/src/index.ts +++ b/packages/interaction/permission/src/index.ts @@ -10,8 +10,8 @@ * @module dsh-permission */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' @@ -33,7 +33,7 @@ import type { PermissionSelect, PresetOption } from './types.ts' // consuming the declarations still receive the SessionProjectionMap merge. export type * from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { permission: PermissionService } diff --git a/packages/interaction/permission/src/invariant.ts b/packages/interaction/permission/src/invariant.ts index 3bd102645f..b403c9e576 100644 --- a/packages/interaction/permission/src/invariant.ts +++ b/packages/interaction/permission/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned permission-preset event invariants. @module @deepseek-ai/dsh-permission/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/interaction/permission/tests/invariant.spec.ts b/packages/interaction/permission/tests/invariant.spec.ts index 9b903dc6a5..7c916f5b58 100644 --- a/packages/interaction/permission/tests/invariant.spec.ts +++ b/packages/interaction/permission/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import * as PermissionInvariant from '@deepseek-ai/dsh-permission/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/interaction/permission/tests/permission.spec.ts b/packages/interaction/permission/tests/permission.spec.ts index 4940e3b155..d20f513b04 100644 --- a/packages/interaction/permission/tests/permission.spec.ts +++ b/packages/interaction/permission/tests/permission.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { ApprovalPolicy } from '@deepseek-ai/dsh-user-approval' diff --git a/packages/interaction/permission/tests/projection.spec.ts b/packages/interaction/permission/tests/projection.spec.ts index 3a0e2091c9..e9bb098967 100644 --- a/packages/interaction/permission/tests/projection.spec.ts +++ b/packages/interaction/permission/tests/projection.spec.ts @@ -10,7 +10,7 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index 7418c7ba2c..dc025017e0 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userInteraction seam", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/interaction/tool-ask-user" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -38,6 +45,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/tool-ask-user/src/index.ts b/packages/interaction/tool-ask-user/src/index.ts index ccfdefe6d3..f223053171 100644 --- a/packages/interaction/tool-ask-user/src/index.ts +++ b/packages/interaction/tool-ask-user/src/index.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-tool-ask-user */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import '@deepseek-ai/dsh-user-interaction' diff --git a/packages/interaction/tool-ask-user/src/invariant.ts b/packages/interaction/tool-ask-user/src/invariant.ts index 140bbd79c5..d723a4bc31 100644 --- a/packages/interaction/tool-ask-user/src/invariant.ts +++ b/packages/interaction/tool-ask-user/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ask-user' diff --git a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts index 14e71ae15b..2795ffa3ff 100644 --- a/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts +++ b/packages/interaction/tool-ask-user/tests/tool-ask-user.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 5b5c43cb42..37b29c4697 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-user-approval", "description": "User-approval seam (ctx.approval) for the DeepSeek Harness: one-shot permission decisions dispatched to composed answerers over the approval/request waterfall, fail-closed by default", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/interaction/user-approval" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -30,17 +37,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -50,6 +57,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/user-approval/src/index.ts b/packages/interaction/user-approval/src/index.ts index f0b63080ed..b0618f6b3d 100644 --- a/packages/interaction/user-approval/src/index.ts +++ b/packages/interaction/user-approval/src/index.ts @@ -5,8 +5,8 @@ */ import { randomUUID } from 'node:crypto' -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type CallId } from '@deepseek-ai/dsh-llm' import { scopeTarget } from '@deepseek-ai/dsh-scope' @@ -14,7 +14,7 @@ import type { Scoped } from '@deepseek-ai/dsh-scope' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-system-prompt' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { approval: ApprovalService } diff --git a/packages/interaction/user-approval/src/invariant.ts b/packages/interaction/user-approval/src/invariant.ts index 6eca2571ff..bf3ca8d18d 100644 --- a/packages/interaction/user-approval/src/invariant.ts +++ b/packages/interaction/user-approval/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned approval audit-stream invariants. @module @deepseek-ai/dsh-user-approval/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ApprovalRequestId } from './index.ts' diff --git a/packages/interaction/user-approval/tests/approval.spec.ts b/packages/interaction/user-approval/tests/approval.spec.ts index 2bb66caa9d..3271ecc7a5 100644 --- a/packages/interaction/user-approval/tests/approval.spec.ts +++ b/packages/interaction/user-approval/tests/approval.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { carrierKeyOf, createScope } from '@deepseek-ai/dsh-scope' diff --git a/packages/interaction/user-approval/tests/invariant.spec.ts b/packages/interaction/user-approval/tests/invariant.spec.ts index bf23b4106d..42665b81f2 100644 --- a/packages/interaction/user-approval/tests/invariant.spec.ts +++ b/packages/interaction/user-approval/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import { ApprovalRequestId } from '@deepseek-ai/dsh-user-approval' import * as ApprovalInvariant from '@deepseek-ai/dsh-user-approval/invariant' diff --git a/packages/interaction/user-interaction/package.json b/packages/interaction/user-interaction/package.json index 5bed79e075..fe90d7534c 100644 --- a/packages/interaction/user-interaction/package.json +++ b/packages/interaction/user-interaction/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-user-interaction", "description": "Abstract user-interaction seam (ctx.userInteraction) for asking the human during agent runs", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/interaction/user-interaction" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -30,15 +37,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/user-interaction/src/index.ts b/packages/interaction/user-interaction/src/index.ts index 82ab06e25b..79c22b8479 100644 --- a/packages/interaction/user-interaction/src/index.ts +++ b/packages/interaction/user-interaction/src/index.ts @@ -7,11 +7,11 @@ * @module @deepseek-ai/dsh-user-interaction */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { userInteraction: UserInteractionService } diff --git a/packages/interaction/user-interaction/src/invariant.ts b/packages/interaction/user-interaction/src/invariant.ts index f4f2f2f31e..3e82a2f73e 100644 --- a/packages/interaction/user-interaction/src/invariant.ts +++ b/packages/interaction/user-interaction/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-user-interaction' diff --git a/packages/interaction/user-interaction/tests/user-interaction.spec.ts b/packages/interaction/user-interaction/tests/user-interaction.spec.ts index 8270b81bfd..44fc075233 100644 --- a/packages/interaction/user-interaction/tests/user-interaction.spec.ts +++ b/packages/interaction/user-interaction/tests/user-interaction.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import UserInteractionService, { UserInteractionError, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index e745fa1169..834e6c5387 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/llm/llm-deepseek" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-credentials": "^0.0.1", - "@deepseek-ai/dsh-environment": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-settings": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "eventsource-parser": "^3.1.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", @@ -44,6 +51,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/llm/llm-deepseek/src/index.ts b/packages/llm/llm-deepseek/src/index.ts index 6d052edc45..f77b36a9cc 100644 --- a/packages/llm/llm-deepseek/src/index.ts +++ b/packages/llm/llm-deepseek/src/index.ts @@ -11,8 +11,8 @@ * @module @deepseek-ai/dsh-llm-deepseek */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { assertUsableApiKey, LlmError, resolveRetryPolicy, RetryPolicySchema } from '@deepseek-ai/dsh-llm' import type { RetryPolicyConfig } from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' diff --git a/packages/llm/llm-deepseek/src/invariant.ts b/packages/llm/llm-deepseek/src/invariant.ts index dd2df6e99c..921e02a252 100644 --- a/packages/llm/llm-deepseek/src/invariant.ts +++ b/packages/llm/llm-deepseek/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-deepseek' diff --git a/packages/llm/llm-deepseek/tests/adapter.e2e.ts b/packages/llm/llm-deepseek/tests/adapter.e2e.ts index 97ae629001..e8eeb7b403 100644 --- a/packages/llm/llm-deepseek/tests/adapter.e2e.ts +++ b/packages/llm/llm-deepseek/tests/adapter.e2e.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, ReasoningEffortId , createMessage } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import { CredentialsLocal } from '@deepseek-ai/dsh-credentials-local' diff --git a/packages/llm/llm-deepseek/tests/adapter.spec.ts b/packages/llm/llm-deepseek/tests/adapter.spec.ts index 64e2bb1e7d..32a19776b2 100644 --- a/packages/llm/llm-deepseek/tests/adapter.spec.ts +++ b/packages/llm/llm-deepseek/tests/adapter.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createEnvironmentSnapshot } from '@deepseek-ai/dsh-environment' import LlmService, { createUserMessage, CONTEXT_WINDOW_EXCEEDED_CODE, diff --git a/packages/llm/llm-deepseek/tests/assemble.ts b/packages/llm/llm-deepseek/tests/assemble.ts index 490b4e87cf..2ed51d0f3d 100644 --- a/packages/llm/llm-deepseek/tests/assemble.ts +++ b/packages/llm/llm-deepseek/tests/assemble.ts @@ -6,7 +6,7 @@ */ import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { FinishReason, GenerateOptions, Message, TokenUsage } from '@deepseek-ai/dsh-llm' export interface AssembledResult { diff --git a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts index 769264b11b..ec6da80b17 100644 --- a/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-deepseek/tests/dynamic-config.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts index 340bed3ee9..d87c6bfe6c 100644 --- a/packages/llm/llm-deepseek/tests/loader-composition.spec.ts +++ b/packages/llm/llm-deepseek/tests/loader-composition.spec.ts @@ -13,9 +13,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import { credentialRef } from '@deepseek-ai/dsh-credentials' import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 9804a35a0f..3d36802a3e 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-llm-pi-ai", "description": "pi-ai-backed DeepSeek adapter for the DeepSeek Harness LLM seam (design-verification twin of dsh-llm-deepseek)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/llm/llm-pi-ai" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,18 +32,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-attachment": "^0.0.1", - "@deepseek-ai/dsh-credentials": "^0.0.1", - "@deepseek-ai/dsh-environment": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-settings": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@earendil-works/pi-ai": "^0.82.1", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", @@ -47,6 +54,6 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/llm/llm-pi-ai/src/config.ts b/packages/llm/llm-pi-ai/src/config.ts index a2074302e8..e6d2c0849b 100644 --- a/packages/llm/llm-pi-ai/src/config.ts +++ b/packages/llm/llm-pi-ai/src/config.ts @@ -15,7 +15,7 @@ */ import type { CacheRetention, ModelThinkingLevel, Provider, ThinkingBudgets, Transport } from '@earendil-works/pi-ai' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { credentialRef } from '@deepseek-ai/dsh-credentials' import type { CredentialRef } from '@deepseek-ai/dsh-credentials' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' diff --git a/packages/llm/llm-pi-ai/src/index.ts b/packages/llm/llm-pi-ai/src/index.ts index 9deea3e1a8..199c98bff3 100644 --- a/packages/llm/llm-pi-ai/src/index.ts +++ b/packages/llm/llm-pi-ai/src/index.ts @@ -55,7 +55,7 @@ * @module @deepseek-ai/dsh-llm-pi-ai */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' import { assertUsableApiKey, LlmError } from '@deepseek-ai/dsh-llm' import type { AdapterRegistrationHandle, DirectoryRegistrationHandle, LlmConfigurableProvider } from '@deepseek-ai/dsh-llm' diff --git a/packages/llm/llm-pi-ai/src/invariant.ts b/packages/llm/llm-pi-ai/src/invariant.ts index a096804fd2..6fdf5b3126 100644 --- a/packages/llm/llm-pi-ai/src/invariant.ts +++ b/packages/llm/llm-pi-ai/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-pi-ai' diff --git a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts index 4637a75298..1232ca3484 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.e2e.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, ReasoningEffortId } from '@deepseek-ai/dsh-llm' import type { Message, ToolSchema } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' diff --git a/packages/llm/llm-pi-ai/tests/adapter.spec.ts b/packages/llm/llm-pi-ai/tests/adapter.spec.ts index e0b54cecd0..1bc370f126 100644 --- a/packages/llm/llm-pi-ai/tests/adapter.spec.ts +++ b/packages/llm/llm-pi-ai/tests/adapter.spec.ts @@ -1,5 +1,5 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, diff --git a/packages/llm/llm-pi-ai/tests/assemble.ts b/packages/llm/llm-pi-ai/tests/assemble.ts index 61726fd1d4..8d415941c7 100644 --- a/packages/llm/llm-pi-ai/tests/assemble.ts +++ b/packages/llm/llm-pi-ai/tests/assemble.ts @@ -6,7 +6,7 @@ */ import { BlockAssembler } from '@deepseek-ai/dsh-llm' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { FinishReason, GenerateOptions, Message, TokenUsage } from '@deepseek-ai/dsh-llm' export interface AssembledResult { diff --git a/packages/llm/llm-pi-ai/tests/catalog.spec.ts b/packages/llm/llm-pi-ai/tests/catalog.spec.ts index e304dddade..38c4184e0a 100644 --- a/packages/llm/llm-pi-ai/tests/catalog.spec.ts +++ b/packages/llm/llm-pi-ai/tests/catalog.spec.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage } from '@deepseek-ai/dsh-llm' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import SettingsLocal from '@deepseek-ai/dsh-settings-local' diff --git a/packages/llm/llm-pi-ai/tests/discovery.spec.ts b/packages/llm/llm-pi-ai/tests/discovery.spec.ts index 02b859d5f2..d4efce4109 100644 --- a/packages/llm/llm-pi-ai/tests/discovery.spec.ts +++ b/packages/llm/llm-pi-ai/tests/discovery.spec.ts @@ -1,7 +1,7 @@ import { createServer } from 'node:http' import type { IncomingMessage, Server, ServerResponse } from 'node:http' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { userAgent } from '@deepseek-ai/dsh-llm' import * as LlmPiAi from '@deepseek-ai/dsh-llm-pi-ai' import { getBuiltinModels } from '@earendil-works/pi-ai/providers/all' diff --git a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts index d30cf18cf9..85987088a3 100644 --- a/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts +++ b/packages/llm/llm-pi-ai/tests/dynamic-config.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts index d5eed60e5e..e0800ed88d 100644 --- a/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts +++ b/packages/llm/llm-pi-ai/tests/loader-composition.spec.ts @@ -13,9 +13,9 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' import SettingsLocal from '@deepseek-ai/dsh-settings-local' diff --git a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts index 002de2b829..e64fb12377 100644 --- a/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts +++ b/packages/llm/llm-pi-ai/tests/provider-apis.e2e.ts @@ -1,6 +1,6 @@ import { readFile } from 'node:fs/promises' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { AttachmentId, AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 809b5d14e5..da8358c620 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/llm/llm-retry" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -29,21 +36,21 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -57,6 +64,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/llm/llm-retry/src/index.ts b/packages/llm/llm-retry/src/index.ts index 2ebeb79260..45db304059 100644 --- a/packages/llm/llm-retry/src/index.ts +++ b/packages/llm/llm-retry/src/index.ts @@ -6,8 +6,8 @@ */ import { randomUUID } from 'node:crypto' -import type { Context, Events } from 'cordis' -import z from 'schemastery' +import type { Context, Events } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, RequestErrorAction } from '@deepseek-ai/dsh-agent' import type { LlmFailure, ResolvedRetryPolicy } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/llm/llm-retry/src/invariant.ts b/packages/llm/llm-retry/src/invariant.ts index 1680873c0c..7b454bcc4e 100644 --- a/packages/llm/llm-retry/src/invariant.ts +++ b/packages/llm/llm-retry/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned durable retry-event invariants. @module @deepseek-ai/dsh-llm-retry/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { LlmFailure } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' diff --git a/packages/llm/llm-retry/tests/invariant.spec.ts b/packages/llm/llm-retry/tests/invariant.spec.ts index dd22e11369..eccaeef4d8 100644 --- a/packages/llm/llm-retry/tests/invariant.spec.ts +++ b/packages/llm/llm-retry/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId, type Session } from '@deepseek-ai/dsh-session' import { createUserMessage, ProviderRequestId } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' diff --git a/packages/llm/llm-retry/tests/loader-composition.spec.ts b/packages/llm/llm-retry/tests/loader-composition.spec.ts index 16fb8581b9..c560720c76 100644 --- a/packages/llm/llm-retry/tests/loader-composition.spec.ts +++ b/packages/llm/llm-retry/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } 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 Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import AgentRegistry from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import LlmService, { createUserMessage, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' diff --git a/packages/llm/llm-retry/tests/persistence.spec.ts b/packages/llm/llm-retry/tests/persistence.spec.ts index 8affb9e059..de20b69698 100644 --- a/packages/llm/llm-retry/tests/persistence.spec.ts +++ b/packages/llm/llm-retry/tests/persistence.spec.ts @@ -2,7 +2,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' diff --git a/packages/llm/llm-retry/tests/retry.spec.ts b/packages/llm/llm-retry/tests/retry.spec.ts index 7268c19ec6..62423ecfda 100644 --- a/packages/llm/llm-retry/tests/retry.spec.ts +++ b/packages/llm/llm-retry/tests/retry.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from 'cordis' -import type { Fiber } from 'cordis' +import { Context } from '@deepseek-ai/cordis' +import type { Fiber } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, CallId, EMPTY_RESPONSE_CODE, LlmAdapter, LlmError, resolveRetryPolicy } from '@deepseek-ai/dsh-llm' import type { AlwaysRetryPolicyConfig, diff --git a/packages/llm/llm-retry/tests/transport-recovery.spec.ts b/packages/llm/llm-retry/tests/transport-recovery.spec.ts index 3acb8605fc..fb830f9868 100644 --- a/packages/llm/llm-retry/tests/transport-recovery.spec.ts +++ b/packages/llm/llm-retry/tests/transport-recovery.spec.ts @@ -2,7 +2,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { createServer } from 'node:http' import type { AddressInfo } from 'node:net' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index c802ce5c43..523ab16cb3 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/llm/llm" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -38,20 +45,20 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-attachment": "^0.0.1", - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-attachment": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/llm/llm/src/attribution.ts b/packages/llm/llm/src/attribution.ts index 79cef011de..42980939cc 100644 --- a/packages/llm/llm/src/attribution.ts +++ b/packages/llm/llm/src/attribution.ts @@ -27,7 +27,7 @@ export interface AppIdentity { product: string /** Product version; sourced from package metadata, never hand-copied. */ version: string - /** Public home URL of the app, used as the `User-Agent` comment. */ + /** Repository home URL of the app, used as the `User-Agent` comment. */ url: string } @@ -40,8 +40,7 @@ export interface AppIdentity { export const APP_IDENTITY: AppIdentity = { product: 'deepseek-harness', version, - // TODO(public-home): Ensure this public source repository exists before release. - url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', + url: 'https://github.com/deepseek-ai/deepseek-harness', } /** diff --git a/packages/llm/llm/src/index.ts b/packages/llm/llm/src/index.ts index 75d8d6f364..9938d687ac 100644 --- a/packages/llm/llm/src/index.ts +++ b/packages/llm/llm/src/index.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-llm */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { GenerateOptions, LlmConfigurableProvider, @@ -43,7 +43,7 @@ export { BlockAssembler } from './assembler.ts' export { callConfigEquals, deepFreeze, isAgentLoopRequest, markAgentLoopRequest } from './call-config.ts' export type { LlmCallConfig, LlmCallConfigAdapterDefaults } from './call-config.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { llm: LlmService } diff --git a/packages/llm/llm/src/invariant.ts b/packages/llm/llm/src/invariant.ts index 2f1afb5155..2106ca354d 100644 --- a/packages/llm/llm/src/invariant.ts +++ b/packages/llm/llm/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned LLM stream-protocol invariants. @module @deepseek-ai/dsh-llm/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { ContentBlockType, StreamChunk } from './types.ts' diff --git a/packages/llm/llm/src/retry-policy.ts b/packages/llm/llm/src/retry-policy.ts index 6f5548a111..ad9c7af65c 100644 --- a/packages/llm/llm/src/retry-policy.ts +++ b/packages/llm/llm/src/retry-policy.ts @@ -7,7 +7,7 @@ * @module @deepseek-ai/dsh-llm/retry-policy */ -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { EMPTY_RESPONSE_CODE } from './error.ts' diff --git a/packages/llm/llm/tests/attribution.spec.ts b/packages/llm/llm/tests/attribution.spec.ts index db74773314..cfe84051dc 100644 --- a/packages/llm/llm/tests/attribution.spec.ts +++ b/packages/llm/llm/tests/attribution.spec.ts @@ -21,7 +21,7 @@ describe('APP_IDENTITY', () => { expect(APP_IDENTITY).toEqual({ product: 'deepseek-harness', version: manifest.version, - url: 'https://github.com/deepseek-ai/deepseek-harness-sdk', + url: 'https://github.com/deepseek-ai/deepseek-harness', }) }) }) @@ -29,7 +29,7 @@ describe('APP_IDENTITY', () => { describe('userAgent', () => { it('renders product/version with the +url comment', () => { expect(userAgent()).toBe( - `deepseek-harness/${manifest.version} (+https://github.com/deepseek-ai/deepseek-harness-sdk)`, + `deepseek-harness/${manifest.version} (+https://github.com/deepseek-ai/deepseek-harness)`, ) }) diff --git a/packages/llm/llm/tests/invariant.spec.ts b/packages/llm/llm/tests/invariant.spec.ts index 8aebb556c2..cfc51c67ee 100644 --- a/packages/llm/llm/tests/invariant.spec.ts +++ b/packages/llm/llm/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { CallId, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' import * as LlmInvariant from '@deepseek-ai/dsh-llm/invariant' diff --git a/packages/llm/llm/tests/service.spec.ts b/packages/llm/llm/tests/service.spec.ts index a758fab415..8873d19859 100644 --- a/packages/llm/llm/tests/service.spec.ts +++ b/packages/llm/llm/tests/service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { errorChain, GenerateOptions, diff --git a/packages/llm/llm/tests/topology.spec.ts b/packages/llm/llm/tests/topology.spec.ts index 8577e14b7c..c31948f61a 100644 --- a/packages/llm/llm/tests/topology.spec.ts +++ b/packages/llm/llm/tests/topology.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { LlmAdapter, LlmError } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, LlmConfigurableProvider, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 5670fe97cd..ef08734fe2 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/llm/token-meter" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -30,15 +37,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-compact": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-projection": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { @@ -47,6 +54,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/llm/token-meter/src/index.ts b/packages/llm/token-meter/src/index.ts index a5ac463fb2..6d7d7ffe49 100644 --- a/packages/llm/token-meter/src/index.ts +++ b/packages/llm/token-meter/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-token-meter */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' import type { Message, TokenUsage } from '@deepseek-ai/dsh-llm' import type { EpochHeader, Session, SessionEvent } from '@deepseek-ai/dsh-session' @@ -64,7 +64,7 @@ function validateConfigKeys(config: TokenMeterConfig): void { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { tokenMeter: TokenMeterService } diff --git a/packages/llm/token-meter/src/invariant.ts b/packages/llm/token-meter/src/invariant.ts index 53ae13c466..c65f4f27b8 100644 --- a/packages/llm/token-meter/src/invariant.ts +++ b/packages/llm/token-meter/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-token-meter' diff --git a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts index 9bdd1222f0..460b1a54f9 100644 --- a/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts +++ b/packages/llm/token-meter/tests/context-breakdown-projection.spec.ts @@ -2,7 +2,7 @@ // plus the shared estimator's pricing branches. import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, ToolSchema } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/llm/token-meter/tests/token-meter.spec.ts b/packages/llm/token-meter/tests/token-meter.spec.ts index e0bf348019..2ca24cf88d 100644 --- a/packages/llm/token-meter/tests/token-meter.spec.ts +++ b/packages/llm/token-meter/tests/token-meter.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, createMessage } from '@deepseek-ai/dsh-llm' import type { ContentBlock, Message, TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId, canonicalHeader } from '@deepseek-ai/dsh-session' diff --git a/packages/llm/token-meter/tests/token-usage-projection.spec.ts b/packages/llm/token-meter/tests/token-usage-projection.spec.ts index 9ccc2d7e9b..173bb8056b 100644 --- a/packages/llm/token-meter/tests/token-usage-projection.spec.ts +++ b/packages/llm/token-meter/tests/token-usage-projection.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import type { TokenUsage } from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index a5411bab55..f31e48080f 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-lsp-local", "description": "Generic stdio language-server provider for the DeepSeek Harness LSP capability seam (ctx.lsp) — spawns configured servers, translates JSON-RPC, and serves transient-open goToDefinition/findReferences/goToImplementation/hover queries in the host filesystem namespace", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/lsp/lsp-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-lsp": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-lsp": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -47,7 +54,7 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "typescript": "^6.0.3", "typescript-language-server": "^5.0.0" } diff --git a/packages/lsp/lsp-local/src/index.ts b/packages/lsp/lsp-local/src/index.ts index fc7e50787f..13d032c5fd 100644 --- a/packages/lsp/lsp-local/src/index.ts +++ b/packages/lsp/lsp-local/src/index.ts @@ -11,8 +11,8 @@ * @module @deepseek-ai/dsh-lsp-local */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { LspError, LspProviderId } from '@deepseek-ai/dsh-lsp' import type { LspProvider, diff --git a/packages/lsp/lsp-local/src/invariant.ts b/packages/lsp/lsp-local/src/invariant.ts index 52ebd16dc0..505d077eeb 100644 --- a/packages/lsp/lsp-local/src/invariant.ts +++ b/packages/lsp/lsp-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-lsp-local' diff --git a/packages/lsp/lsp-local/tests/built-lib.e2e.ts b/packages/lsp/lsp-local/tests/built-lib.e2e.ts index 75f9f227c3..707f389a2f 100644 --- a/packages/lsp/lsp-local/tests/built-lib.e2e.ts +++ b/packages/lsp/lsp-local/tests/built-lib.e2e.ts @@ -40,7 +40,7 @@ describe.skipIf(!built)('built lib real load path (plain node)', () => { it('runs a query through lib/index.js and disposes cleanly, framing over the base protocol', async () => { const location = JSON.stringify({ uri: pathToFileURL(join(ws, 'a.ts')).href, range: { start: { line: 0, character: 0 }, end: { line: 0, character: 3 } } }) const script = ` - const { Context } = await import('cordis') + const { Context } = await import('@deepseek-ai/cordis') const { default: Lsp } = await import('@deepseek-ai/dsh-lsp') const LspLocal = await import('@deepseek-ai/dsh-lsp-local') const { default: LocalFileSystem } = await import('@deepseek-ai/dsh-fs-local') diff --git a/packages/lsp/lsp-local/tests/host.spec.ts b/packages/lsp/lsp-local/tests/host.spec.ts index c06b6d9df8..8c7a9079d3 100644 --- a/packages/lsp/lsp-local/tests/host.spec.ts +++ b/packages/lsp/lsp-local/tests/host.spec.ts @@ -6,7 +6,7 @@ import { realpath } from 'node:fs/promises' import { execFile } from 'node:child_process' import { pathToFileURL } from 'node:url' import { promisify } from 'node:util' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import { deadline } from '@deepseek-ai/dsh-timeout' import { canonicalizeWorkspace, readHostSource } from '@deepseek-ai/dsh-lsp-local' diff --git a/packages/lsp/lsp-local/tests/instance.spec.ts b/packages/lsp/lsp-local/tests/instance.spec.ts index 08bad67ae2..0a928fba50 100644 --- a/packages/lsp/lsp-local/tests/instance.spec.ts +++ b/packages/lsp/lsp-local/tests/instance.spec.ts @@ -4,7 +4,7 @@ import { mkdtemp, mkdir, readFile, rm, writeFile, realpath } from 'node:fs/promi import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import { LspInstance, readHostSource } from '@deepseek-ai/dsh-lsp-local' import { encodeMessage } from '@deepseek-ai/dsh-lsp-local' diff --git a/packages/lsp/lsp-local/tests/lifecycle.spec.ts b/packages/lsp/lsp-local/tests/lifecycle.spec.ts index 20c4bc7bf0..bda3963d7d 100644 --- a/packages/lsp/lsp-local/tests/lifecycle.spec.ts +++ b/packages/lsp/lsp-local/tests/lifecycle.spec.ts @@ -4,7 +4,7 @@ import { realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL, fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import Lsp, { type LspProvider, type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' import { deadline } from '@deepseek-ai/dsh-timeout' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/lsp/lsp-local/tests/provider.spec.ts b/packages/lsp/lsp-local/tests/provider.spec.ts index 24edca4795..94486e1ea7 100644 --- a/packages/lsp/lsp-local/tests/provider.spec.ts +++ b/packages/lsp/lsp-local/tests/provider.spec.ts @@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { chmod, mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter, join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import Lsp, { type LspQueryRequest } from '@deepseek-ai/dsh-lsp' diff --git a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts index 9bdb7d1fd4..5879ed3b2f 100644 --- a/packages/lsp/lsp-local/tests/typescript-server.e2e.ts +++ b/packages/lsp/lsp-local/tests/typescript-server.e2e.ts @@ -9,7 +9,7 @@ import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' import Lsp, { type LspQueryRequest, type LspQueryResult } from '@deepseek-ai/dsh-lsp' diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index b4a962fb34..6395749c68 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-lsp", "description": "Abstract LSP capability seam (ctx.lsp) for the DeepSeek Harness — language-server provider registry keyed by branded id and extension mapping, order-independent per-query selection, normalized definition/references/implementation/hover requests and results, and the LspError taxonomy", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/lsp/lsp" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/lsp/lsp/src/index.ts b/packages/lsp/lsp/src/index.ts index ec780b76f1..05e2cd3643 100644 --- a/packages/lsp/lsp/src/index.ts +++ b/packages/lsp/lsp/src/index.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-lsp */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { LspProviderId } from './brand.ts' import type { @@ -35,7 +35,7 @@ export type { LspService, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { lsp: LspService } diff --git a/packages/lsp/lsp/src/invariant.ts b/packages/lsp/lsp/src/invariant.ts index 27481309f4..512775798b 100644 --- a/packages/lsp/lsp/src/invariant.ts +++ b/packages/lsp/lsp/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-lsp' diff --git a/packages/lsp/lsp/tests/lsp.spec.ts b/packages/lsp/lsp/tests/lsp.spec.ts index 86c9b0ac53..0adbf44769 100644 --- a/packages/lsp/lsp/tests/lsp.spec.ts +++ b/packages/lsp/lsp/tests/lsp.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import Lsp, { finalExtension, LspError, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index f757df35fd..d4003a61e5 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-lsp", "description": "Model-facing lsp tool over the DeepSeek Harness LSP capability seam (ctx.lsp) — one read-only tool with goToDefinition/findReferences/goToImplementation/hover operations, one-based UTF-16 cursor coordinates, bounded location rendering, and hover normalization", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/lsp/tool-lsp" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-lsp": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-lsp": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -49,6 +56,6 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/lsp/tool-lsp/src/index.ts b/packages/lsp/tool-lsp/src/index.ts index 688b48b369..f94cb7d5f7 100644 --- a/packages/lsp/tool-lsp/src/index.ts +++ b/packages/lsp/tool-lsp/src/index.ts @@ -10,8 +10,8 @@ * @module @deepseek-ai/dsh-tool-lsp */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import { assertNever } from '@deepseek-ai/dsh-llm' import { LspError } from '@deepseek-ai/dsh-lsp' diff --git a/packages/lsp/tool-lsp/src/invariant.ts b/packages/lsp/tool-lsp/src/invariant.ts index a2516e059b..fc9a292fc8 100644 --- a/packages/lsp/tool-lsp/src/invariant.ts +++ b/packages/lsp/tool-lsp/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-lsp' diff --git a/packages/lsp/tool-lsp/tests/integration.spec.ts b/packages/lsp/tool-lsp/tests/integration.spec.ts index 8312a6e3ab..8ad2392adc 100644 --- a/packages/lsp/tool-lsp/tests/integration.spec.ts +++ b/packages/lsp/tool-lsp/tests/integration.spec.ts @@ -3,7 +3,7 @@ import { mkdtemp, mkdir, rm, writeFile, realpath } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' diff --git a/packages/lsp/tool-lsp/tests/load-path.spec.ts b/packages/lsp/tool-lsp/tests/load-path.spec.ts index 7b3ec0f241..f5f2b272d7 100644 --- a/packages/lsp/tool-lsp/tests/load-path.spec.ts +++ b/packages/lsp/tool-lsp/tests/load-path.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import Loader from '@cordisjs/plugin-loader' +import Loader from '@deepseek-ai/cordis-plugin-loader' import * as toolLsp from '@deepseek-ai/dsh-tool-lsp' describe('dsh-tool-lsp Loader export-shape guard', () => { diff --git a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts index 846bd702ea..bc6795273e 100644 --- a/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts +++ b/packages/lsp/tool-lsp/tests/tool-lsp.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import Lsp, { LspProviderId, type LspProvider, type LspProviderQuery, type LspQueryResult } from '@deepseek-ai/dsh-lsp' diff --git a/packages/mcp/mcp-client/README.i18n.yaml b/packages/mcp/mcp-client/README.i18n.yaml index 9568c08454..c66d8ffba8 100644 --- a/packages/mcp/mcp-client/README.i18n.yaml +++ b/packages/mcp/mcp-client/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/mcp/mcp-client/README.md -README.md: 76d1271f6f7a3e9c959bdcf5e969906f25563c56 -README.zh.md: b2da1119af3a8d52761a5040059e7a9d922aa567 +README.md: 97ac9c173fc2b848f524e8c0fdd93eca072567af +README.zh.md: 1b5b5c523e0a477db30f97a748651dbe7e6992ea diff --git a/packages/mcp/mcp-client/README.md b/packages/mcp/mcp-client/README.md index 76d1271f6f..97ac9c173f 100644 --- a/packages/mcp/mcp-client/README.md +++ b/packages/mcp/mcp-client/README.md @@ -45,6 +45,10 @@ The model sees `mcp__github__create_issue`, `mcp__web__search`, … — the same | `headers` | http | no | Extra headers (e.g. auth tokens) | | `toolCallTimeoutMs` | both | no | Timeout per `callTool` invocation (default 60000) | | `failOnStartupError` | both | no | Reject plugin activation when initial connection or tool synchronization fails (default `false`) | +| `reconnect.enabled` | both | no | Reconnect automatically after a lost connection (default `true`) | +| `reconnect.initialDelayMs` | both | no | First reconnect delay in ms; doubles per consecutive failed attempt (default 500) | +| `reconnect.maxDelayMs` | both | no | Backoff ceiling in ms; also the uptime after which the attempt budget resets (default 30000) | +| `reconnect.maxAttempts` | both | no | Consecutive failed attempts per outage before giving up for good (default 10) | ## Tool naming @@ -62,7 +66,9 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` - Tool execute: `client.callTool({ name: rawName, arguments }, { signal })` with timeout + abort support—the public name is never sent to the server. - Canonical success is `{ content: JsonValue[], structuredContent? }`; complete JSON MCP blocks survive for programmatic callers. A supported advertised `outputSchema` validates `structuredContent`; unsupported schema vocabulary falls back to unconstrained `JsonValue`. - Native/model rendering keeps the existing text projection: text blocks join with newlines while image, audio, resource, and unsupported blocks become placeholders. -- On disconnect/crash: no auto-reconnect. Registered tools remain until plugin disposal or a successful re-sync, and calls can fail against the closed transport; reload with HMR or restart the Host to reconnect. +- On disconnect/crash: the supervisor restarts the original server config with exponential backoff (`reconnect.initialDelayMs` doubling up to `reconnect.maxDelayMs`) and re-runs discovery on success — the recovered generation replaces the previous one, so tools neither duplicate nor leak. During the outage the last good generation stays registered; calls against it fail until recovery. +- Reconnection is budgeted per outage: after `reconnect.maxAttempts` consecutive failures the server's tools are unregistered and reconnection stops until an HMR reload or Host restart. A connection that survives past `maxDelayMs` resets the budget, so an occasionally-crashing server recovers indefinitely while a crash-looping one — even with briefly successful connects — still exhausts the cap instead of restarting forever. +- Reconnect states are user-visible in logs: reconnecting (warn, with attempt count and delay), recovered (info), final failure and disabled-loss (error). Disposal cancels any pending reconnect. With `reconnect.enabled: false`, a lost connection keeps tools registered but failing until a reload — the manual-recovery behavior. ## Services consumed @@ -76,7 +82,7 @@ Every MCP tool has two names: the raw MCP name (sent on the wire in `tools/call` #### What the model sees -After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp____` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync replaces the generation; plugin disposal removes it. +After initial discovery succeeds, each advertised MCP tool appears as a native tool named `mcp____` (or its deterministic normalized form), with the server-provided description and input schema. A successful re-sync — including the one after an automatic reconnect — replaces the generation; plugin disposal or an exhausted reconnect budget removes it. #### Token effect @@ -84,7 +90,7 @@ Data-dependent schema cost is paid on every request while the tools are register #### KV Cache effect -Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync that adds, removes, renames, or changes a tool replaces definitions and may invalidate reuse from the first changed schema token. +Prefix-stable while the discovered tool set and schemas are unchanged. A re-sync that adds, removes, renames, or changes a tool replaces definitions and may invalidate reuse from the first changed schema token; a reconnect that recovers an unchanged list reproduces identical definitions and stays prefix-stable. ### Tool-call history and results @@ -104,6 +110,6 @@ Append-only; newly visible content follows the reusable request prefix and does - **Tools are the only bridged MCP capability** — Resources and Prompts have no harness consumption surface and are deferred. - **Startup timeout is inherited from the MCP SDK** — DSH does not yet expose a connection/discovery timeout. Each initialize or paginated `tools/list` request uses the SDK's 60-second default, so an unresponsive server or cursor chain can delay both activation and teardown while the initial synchronization settles. -- **Crash recovery is manual** — transport closure does not auto-reconnect; registered tools can remain visible but fail against the closed transport until an HMR reload or Host restart. +- **Reconnect triggers on transport close** — a crashed stdio child fires it; Streamable HTTP failures surface per request and through the SDK transport's own SSE-stream recovery, so an unreachable HTTP server is retried per call rather than respawned by the supervisor. - **Native non-text rendering is lossy** — image, audio, and resource payloads become placeholders in model context even though the execution-local canonical value preserves their JSON blocks. Richer Native multimedia projection is deferred. - **Unsupported MCP output schemas are not enforced** — `structuredContent` falls back to `JsonValue` when the advertised schema uses vocabulary outside the harness subset. diff --git a/packages/mcp/mcp-client/README.zh.md b/packages/mcp/mcp-client/README.zh.md index b2da1119af..1b5b5c523e 100644 --- a/packages/mcp/mcp-client/README.zh.md +++ b/packages/mcp/mcp-client/README.zh.md @@ -45,6 +45,10 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc | `headers` | http | 否 | 额外标头(例如认证 token) | | `toolCallTimeoutMs` | 两者 | 否 | 每次 `callTool` 调用的超时(默认 60000) | | `failOnStartupError` | 两者 | 否 | 初始连接或工具同步失败时拒绝插件激活(默认 `false`) | +| `reconnect.enabled` | 两者 | 否 | 连接丢失后自动重新连接(默认 `true`) | +| `reconnect.initialDelayMs` | 两者 | 否 | 首次重连延迟(毫秒);每次连续失败尝试翻倍(默认 500) | +| `reconnect.maxDelayMs` | 两者 | 否 | 退避上限(毫秒);同时也是重置尝试预算所需的正常运行时长(默认 30000) | +| `reconnect.maxAttempts` | 两者 | 否 | 每次中断期间连续失败尝试次数上限,超出后彻底放弃(默认 10) | ## 工具命名 @@ -62,7 +66,9 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc - 工具执行:`client.callTool({ name: rawName, arguments }, { signal })`,支持超时 + 中止;公开名称绝不会发给服务器。 - 规范成功值是 `{ content: JsonValue[], structuredContent? }`;完整的 JSON MCP 块会保留给编程调用方。受支持且已声明的 `outputSchema` 会验证 `structuredContent`;不受支持的 schema 词汇会回退为不受约束的 `JsonValue`。 - Native/模型渲染保留现有文本投影:文本块以换行连接,图片、音频、资源和不受支持的块会变成占位符。 -- 断开/崩溃时:不自动重新连接。已注册工具会一直保留到对插件执行 dispose(资源释放)或成功重新同步,针对已关闭传输的调用可能失败;请通过 HMR 重新加载或重启 Host 来重新连接。 +- 断开/崩溃时:supervisor 以指数退避(`reconnect.initialDelayMs` 逐次翻倍,上限 `reconnect.maxDelayMs`)重启原始服务器配置,成功后重新执行发现——恢复的世代会替换前一个,因此工具既不会重复也不会泄漏。中断期间最后一个正常世代保持注册;针对它的调用在恢复前会失败。 +- 重连按中断预算控制:连续失败达到 `reconnect.maxAttempts` 次后,该服务器的工具会被注销,重连停止,直到 HMR 重载或重启 Host。连接存活超过 `maxDelayMs` 会重置预算,因此偶尔崩溃的服务器可以无限恢复,而崩溃循环的服务器——即使短暂连接成功——仍会耗尽上限而非永远重启。 +- 重连状态在日志中对用户可见:reconnecting(warn,含尝试次数和延迟)、recovered(info)、最终失败和 disabled-loss(error)。dispose(资源释放)会取消任何待执行的重连。设置 `reconnect.enabled: false` 时,连接丢失后工具保持注册但调用失败,直到重载——即手动恢复行为。 ## 消费的服务 @@ -76,7 +82,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc #### 模型看到的内容 -初始发现成功后,每个已声明的 MCP 工具都会显示为名为 `mcp____`(或其确定性规范化形式)的原生工具,并携带服务器提供的描述和输入 schema。成功的重新同步会替换整个世代;对插件执行 dispose 会移除该世代。 +初始发现成功后,每个已声明的 MCP 工具都会显示为名为 `mcp____`(或其确定性规范化形式)的原生工具,并携带服务器提供的描述和输入 schema。成功的重新同步——包括自动重连后的同步——会替换整个世代;对插件执行 dispose(资源释放)或重连预算耗尽会移除该世代。 #### Token 影响 @@ -84,7 +90,7 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc #### KV Cache 影响 -只要已发现工具集合及其 schema 不变,前缀就保持稳定。增加、移除、重命名或更改工具的重新同步会替换定义,并可能使从第一个变化的 schema token 起的复用失效。 +只要已发现工具集合及其 schema 不变,前缀就保持稳定。增加、移除、重命名或更改工具的重新同步会替换定义,并可能使从第一个变化的 schema token 起的复用失效;恢复了未变列表的重连会生成完全相同的定义,前缀保持稳定。 ### 工具调用历史与结果 @@ -104,6 +110,6 @@ MCP 客户端桥接插件:连接外部 [Model Context Protocol](https://modelc - **只桥接 MCP 的工具能力**:资源和提示词没有 harness 消费接口,暂缓实现。 - **启动超时继承自 MCP SDK**:DSH 尚未公开连接/发现超时。每次 initialize 请求或分页 `tools/list` 请求都使用 SDK 默认的 60 秒,因此在初始同步完成期间,无响应的 server 或 cursor chain 可能同时延迟激活与 teardown。 -- **崩溃恢复需要手动触发**:传输关闭后不会自动重新连接;已注册工具可能仍然可见,但会因传输已关闭而调用失败,直到 HMR 重载或重启 Host。 +- **重连在传输关闭时触发**:崩溃的 stdio 子进程会触发重连;Streamable HTTP 失败通过每次请求以及 SDK 传输自身的 SSE(Server-Sent Events)流恢复机制暴露,因此不可达的 HTTP 服务器会按调用重试,而非由 supervisor 重新 spawn。 - **Native 非文本渲染有损**:图片、音频与资源载荷在模型上下文中会变成占位符,即使执行局部的规范值保留了其 JSON 块。更丰富的 Native 多媒体投影暂缓实现。 - **不强制执行不受支持的 MCP 输出 schema**:已声明 schema 使用 harness 子集之外的词汇时,`structuredContent` 会回退到 `JsonValue`。 diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 3f65271905..1cd533c3db 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-mcp-client", "description": "MCP client bridge: connects to MCP servers and registers their tools on ctx.tools", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/mcp/mcp-client" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,24 +32,26 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/mcp/mcp-client/src/connection.ts b/packages/mcp/mcp-client/src/connection.ts new file mode 100644 index 0000000000..f452e3a81d --- /dev/null +++ b/packages/mcp/mcp-client/src/connection.ts @@ -0,0 +1,351 @@ +/** + * Connection supervisor: owns the MCP client/transport generations for one + * plugin instance, keeps the harness tool registry in sync with the live + * generation, and — when the connection drops — restarts the configured + * server with bounded exponential backoff. + * + * One outage shares one attempt budget (`maxAttempts` consecutive failed + * attempts, delays doubling from `initialDelayMs` up to `maxDelayMs`). A + * connection that stays up past the stability window closes the outage, so + * the next disconnect starts a fresh budget while a crash-looping server — + * even one whose connects briefly succeed — still exhausts the cap instead of + * restarting forever. Exhaustion unregisters the server's tools and stops; + * disposal (including HMR) is the only way back from that state. + * + * @module + */ + +import { Client } from '@modelcontextprotocol/sdk/client/index.js' +import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js' +import type { Context } from '@deepseek-ai/cordis' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { createTransport } from './transport.ts' +import { syncTools } from './tools.ts' +import type { ToolBridgeOptions, ToolDisposers } from './tools.ts' +import type { Config } from './index.ts' + +/** Automatic reconnect policy for one MCP server connection. */ +export interface ReconnectConfig { + /** Reconnect automatically after a lost connection (default true). */ + enabled?: boolean + /** First reconnect delay in milliseconds; doubles per consecutive failed attempt (default 500). */ + initialDelayMs?: number + /** Backoff ceiling in milliseconds; also the uptime after which the attempt budget resets (default 30000). */ + maxDelayMs?: number + /** Consecutive failed attempts per outage before giving up for good (default 10). */ + maxAttempts?: number +} + +/** Defaults shared by the Config schema and {@link resolveReconnectPolicy}. */ +export const RECONNECT_DEFAULTS: Required = Object.freeze({ + enabled: true, + initialDelayMs: 500, + maxDelayMs: 30_000, + maxAttempts: 10, +}) + +// The SDK's stdio transport owns two two-second termination grace periods. +// Keep one additional second for the process-close event that proves the old +// generation is gone; timing out fails closed instead of overlapping children. +const GENERATION_CLOSE_TIMEOUT_MS = 5_000 + +/** Fully resolved reconnect policy captured at plugin load. */ +export type ResolvedReconnectPolicy = Readonly> + +/** + * The one explicit resolve step from raw reconnect config to the policy the + * supervisor runs. Programmatic construction may bypass Schemastery + * normalization, so every default and bound is re-judged here — misconfiguration + * fails the plugin instance at load. + * + * @param config - Raw `reconnect` config; omission uses the defaults. + * @param path - Diagnostic prefix naming the config location in thrown messages. + * @returns The frozen resolved policy. + */ +export function resolveReconnectPolicy(config: ReconnectConfig | undefined, path: string): ResolvedReconnectPolicy { + if (config !== undefined) { + for (const key of Object.keys(config)) { + if (!Object.hasOwn(RECONNECT_DEFAULTS, key)) throw new Error(`${path}.${key} is not a reconnect option`) + } + } + const enabled = config?.enabled ?? RECONNECT_DEFAULTS.enabled + const initialDelayMs = config?.initialDelayMs ?? RECONNECT_DEFAULTS.initialDelayMs + const maxDelayMs = config?.maxDelayMs ?? RECONNECT_DEFAULTS.maxDelayMs + const maxAttempts = config?.maxAttempts ?? RECONNECT_DEFAULTS.maxAttempts + /* jscpd:ignore-start — domain-specific delay validation parallels llm retry-policy; not extractable */ + if (!Number.isFinite(initialDelayMs) || initialDelayMs <= 0 || initialDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`${path}.initialDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (!Number.isFinite(maxDelayMs) || maxDelayMs <= 0 || maxDelayMs > MAX_TIMER_DELAY_MS) { + throw new Error(`${path}.maxDelayMs must be a positive finite number no greater than ${MAX_TIMER_DELAY_MS}`) + } + if (initialDelayMs > maxDelayMs) { + throw new Error(`${path}.initialDelayMs must be less than or equal to maxDelayMs`) + } + if (!Number.isInteger(maxAttempts) || maxAttempts < 1) { + throw new Error(`${path}.maxAttempts must be a positive integer`) + } + /* jscpd:ignore-end */ + return Object.freeze({ enabled, initialDelayMs, maxDelayMs, maxAttempts }) +} + +/** Result from the initial connection attempt, for startup-await semantics. */ +export interface ConnectionOutcome { + /** If the initial connection or tool sync failed, the error; otherwise absent. */ + error?: unknown +} + +/** Handle for one plugin instance's supervised connection. */ +export interface ConnectionHandle { + /** + * Settles when the first connection attempt completes (success or failure). + * The supervisor enters its reconnect loop regardless; the caller decides + * whether a failed startup is fatal via `failOnStartupError`. + */ + ready: Promise + /** + * Stop reconnection, close the live client, wait for the in-flight attempt + * and queued tool syncs to quiesce, then unregister every tool this server + * still owns. + */ + dispose(): Promise +} + +/** + * Start the supervised connection for one MCP server and keep it alive per + * the reconnect policy. + * + * @param ctx - Cordis context providing the `tools` registry and logger. + * @param config - Resolved plugin config selecting the transport and server identity. + * @param policy - Resolved reconnect policy from {@link resolveReconnectPolicy}. + * @returns Handle with a `ready` promise for startup-await and a `dispose` for teardown. + */ +export function startConnection(ctx: Context, config: Config, policy: ResolvedReconnectPolicy): ConnectionHandle { + const label = `mcp-client(${config.serverName})` + const opts: ToolBridgeOptions = { + registrationFailure: 'contain', + serverName: config.serverName, + toolCallTimeoutMs: config.toolCallTimeoutMs, + } + // The initial sync uses 'throw' when failOnStartupError is configured, so + // a registration conflict propagates to the startup-await path. Re-syncs + // and reconnect syncs always contain conflicts. + const startupOpts: ToolBridgeOptions = config.failOnStartupError + ? { ...opts, registrationFailure: 'throw' } + : opts + + let disposed = false + /** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */ + let client: Client | undefined + /** Close signal paired with {@link client}; captured by dispose before current ownership is cleared. */ + let clientClosed: Promise | undefined + /** Live tool registrations owned by this server; only {@link enqueueSync} and dispose swap it. */ + let disposers: ToolDisposers = new Map() + let reconnectTimer: NodeJS.Timeout | undefined + /** Consecutive failed connection attempts within the current outage. */ + let failedAttempts = 0 + /** When the current generation finished connect + initial sync; undefined while down. */ + let connectedAt: number | undefined + /** The real error from the first connection attempt, for startup-await diagnostics. */ + let firstAttemptError: unknown + + /** A generation may act only while it is the current one on a live plugin. */ + const isCurrent = (generation: Client): boolean => !disposed && client === generation + + /** + * Serializes every syncTools call — initial syncs and notification re-syncs + * across all generations — so two syncs can never interleave their + * dispose-previous/register-next swap (which would double-dispose one + * generation and leak another). + */ + let syncChain: Promise = Promise.resolve() + function enqueueSync(generation: Client, syncOpts: ToolBridgeOptions = opts): Promise { + const run = syncChain.then(async () => { + if (!isCurrent(generation)) return + disposers = await syncTools(generation, ctx, syncOpts, disposers) + }) + // The chain tail must survive a failed sync; the enqueuing caller owns reporting. + syncChain = run.catch(() => {}) + return run + } + + /** One disconnect decision per generation: the isCurrent guard makes racing close/error signals idempotent. */ + function generationDown(generation: Client): void { + if (!isCurrent(generation)) return + client = undefined + clientClosed = undefined + scheduleReconnect() + } + + /** Wait for the transport-owned close signal without letting a broken transport wedge teardown forever. */ + function waitForClose(closed: Promise): Promise { + return new Promise((resolve) => { + const timeout = setTimeout(() => { resolve(false) }, GENERATION_CLOSE_TIMEOUT_MS) + timeout.unref() + void closed.then(() => { + clearTimeout(timeout) + resolve(true) + }) + }) + } + + function scheduleReconnect(): void { + const lostEstablishedConnection = connectedAt !== undefined + if (!policy.enabled) { + const message = lostEstablishedConnection + ? 'connection lost and reconnect is disabled — registered tools will fail until an HMR reload or Host restart' + : 'connection failed and reconnect is disabled — no tools were registered; reload the plugin or restart the Host to connect' + ctx.logger.error(`${label}: ${message}`) + return + } + // A connection that stayed up past the stability window (= maxDelayMs, the + // longest backoff spacing) ended the previous outage: start a fresh budget. + if (connectedAt !== undefined && Date.now() - connectedAt >= policy.maxDelayMs) failedAttempts = 0 + connectedAt = undefined + failedAttempts += 1 + if (failedAttempts > policy.maxAttempts) { + // Enqueue the give-up disposal so it cannot race an in-flight sync's + // phase-2 swap (which checks isCurrent inside the queue). + syncChain = syncChain.then(() => { + for (const dispose of disposers.values()) dispose() + disposers = new Map() + }) + ctx.logger.error(`${label}: giving up after ${policy.maxAttempts} consecutive failed reconnect attempts — tools unregistered; reload the plugin or restart the Host to reconnect`) + return + } + const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1)) + const action = lostEstablishedConnection ? 'connection lost; reconnecting' : 'connection failed; retrying' + ctx.logger.warn(`${label}: ${action} in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`) + reconnectTimer = setTimeout(() => { + reconnectTimer = undefined + settling = connectGeneration(false) + }, delayMs) + // An armed reconnect timer must never hold the process open on its own. + reconnectTimer.unref() + } + + /** + * One connection attempt: fresh transport + client (the MCP SDK binds a + * Protocol to one transport for life), connect, then queue the initial tool + * sync. The startup flag belongs to the attempt rather than the shared sync + * queue, so an early notification cannot consume strict startup semantics. + * Every failure funnels through {@link generationDown}; success arms the + * onclose-driven disconnect path. Never rejects. + * + * @param startup - Whether this is the plugin's activation attempt. + */ + async function connectGeneration(startup: boolean): Promise { + const generation = new Client( + { name: 'dsh-mcp-client', version: '0.0.1' }, + { capabilities: {} }, + ) + const closed: PromiseWithResolvers = Promise.withResolvers() + let attemptSettled = false + let closeObserved = false + const hasClosed = (): boolean => closeObserved + client = generation + clientClosed = closed.promise + generation.onclose = () => { + closeObserved = true + closed.resolve() + // A failed connect owns its close barrier in the catch path below. An + // established generation can transition down directly from this signal. + if (attemptSettled) generationDown(generation) + } + // Registered before connect so a list change during the initial sync is + // queued behind it rather than dropped. + generation.setNotificationHandler( + ToolListChangedNotificationSchema, + async () => { + if (!isCurrent(generation)) return + ctx.logger.info(`${label}: tool list changed, re-syncing`) + try { + await enqueueSync(generation) + } catch (error) { + // Fetch-phase failure: the previous generation is still registered + // and `disposers` still owns it — keep serving the last good list. + if (!disposed) ctx.logger.error(`${label}: tool re-sync failed: ${String(error)}`) + } + }, + ) + try { + await generation.connect(createTransport(config)) + if (hasClosed()) { + attemptSettled = true + generationDown(generation) + return + } + await enqueueSync(generation, startup ? startupOpts : opts) + } catch (error) { + if (firstAttemptError === undefined) firstAttemptError = error + // Disposal clears current ownership before it closes the generation, so + // only a live supervisor reports an attempt failure. + if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}`) + try { await generation.close() } catch { /* transport already gone */ } + const quiesced = hasClosed() || await waitForClose(closed.promise) + attemptSettled = true + if (!isCurrent(generation)) return + if (!quiesced) { + client = undefined + clientClosed = undefined + ctx.logger.error(`${label}: failed generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms — reconnect stopped to avoid overlapping server processes; reload the plugin or restart the Host to retry`) + return + } + generationDown(generation) + return + } + attemptSettled = true + if (hasClosed()) { + generationDown(generation) + return + } + if (!isCurrent(generation)) return + connectedAt = Date.now() + if (failedAttempts > 0) ctx.logger.info(`${label}: reconnected and re-synced tools (attempt ${failedAttempts}/${policy.maxAttempts})`) + } + + /** The in-flight (or last settled) connection attempt; dispose awaits it for quiescence. */ + let settling = connectGeneration(true) + + // The ready promise settles when the first attempt finishes (regardless of + // success). If the first attempt fails and reconnect is enabled, the + // supervisor is already scheduling a retry — ready just reports the outcome. + const ready: Promise = settling.then(() => { + // After settling: if client is set the initial connect+sync succeeded. + // If not, the supervisor either scheduled a retry (error logged) or gave + // up (error logged). Either way the outcome is reported with the real error. + // Note: settling.then() is a microtask; stdio onclose is a macrotask — so + // a server that crashes AFTER a successful initial sync cannot flip client + // to undefined before this continuation runs. + if (client !== undefined) return {} + /* v8 ignore next -- defensive: firstAttemptError is always set when connect/sync fails */ + return { error: firstAttemptError ?? new Error(`${label}: initial connection failed`) } + }) + + return { + ready, + async dispose(): Promise { + disposed = true + if (reconnectTimer !== undefined) { + clearTimeout(reconnectTimer) + reconnectTimer = undefined + } + const current = client + const currentClosed = clientClosed + client = undefined + clientClosed = undefined + if (current !== undefined) { + try { await current.close() } catch { /* transport already gone */ } + if (currentClosed !== undefined && !await waitForClose(currentClosed)) { + ctx.logger.error(`${label}: generation did not close within ${GENERATION_CLOSE_TIMEOUT_MS}ms during disposal — server shutdown may be incomplete`) + } + } + // Quiesce, don't just request it: the in-flight attempt enqueues its + // sync before settling, so awaiting both leaves `disposers` final. + await settling + await syncChain + for (const dispose of disposers.values()) dispose() + disposers = new Map() + }, + } +} diff --git a/packages/mcp/mcp-client/src/index.ts b/packages/mcp/mcp-client/src/index.ts index 0fe7dd9de3..5a2a52f55d 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -13,16 +13,16 @@ * @module @deepseek-ai/dsh-mcp-client */ -import type { Context } from 'cordis' -import z from 'schemastery' -import { Client } from '@modelcontextprotocol/sdk/client/index.js' -import { ToolListChangedNotificationSchema } from '@modelcontextprotocol/sdk/types.js' -import { createTransport } from './transport.ts' -import { syncTools } from './tools.ts' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' +import { RECONNECT_DEFAULTS, resolveReconnectPolicy, startConnection } from './connection.ts' +import type { ReconnectConfig } from './connection.ts' // Side-effect type import: declaration-merges `ctx.tools` onto Context. import type {} from '@deepseek-ai/dsh-tools' export type { McpResult } from './tools.ts' +export type { ReconnectConfig, ResolvedReconnectPolicy } from './connection.ts' /** Cordis plugin name used by loader diagnostics. */ export const name = 'mcp-client' @@ -33,14 +33,8 @@ export const inject = ['tools'] /** Default timeout for individual MCP tool calls (ms). */ 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. - */ -export const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ +/** Valid `serverName`, kept below the public tool-name budget. */ +const SERVER_NAME_PATTERN = /^[A-Za-z0-9_-]{1,32}$/ /** * Live `serverName` reservations per app, keyed off `ctx.root` (multiple apps @@ -74,6 +68,8 @@ export interface StdioConfig { toolCallTimeoutMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean + /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ + reconnect?: ReconnectConfig } /** Config for connecting to an MCP server over Streamable HTTP (SSE). */ @@ -94,11 +90,20 @@ export interface StreamableHttpConfig { toolCallTimeoutMs: number /** Fail plugin activation when the initial connection or tool synchronization fails. */ failOnStartupError: boolean + /** Automatic reconnect policy after a lost connection; omission uses the defaults. */ + reconnect?: ReconnectConfig } /** Configuration for one stdio or Streamable HTTP MCP server. */ export type Config = StdioConfig | StreamableHttpConfig +const Reconnect: z = z.object({ + enabled: z.boolean().default(RECONNECT_DEFAULTS.enabled), + initialDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.initialDelayMs), + maxDelayMs: z.number().min(1).max(MAX_TIMER_DELAY_MS).default(RECONNECT_DEFAULTS.maxDelayMs), + maxAttempts: z.number().step(1).min(1).max(Number.MAX_SAFE_INTEGER).default(RECONNECT_DEFAULTS.maxAttempts), +}) + export const Config = z.union([ z.object({ transport: z.const('stdio'), @@ -109,6 +114,7 @@ export const Config = z.union([ cwd: z.string().default(''), toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), failOnStartupError: z.boolean().default(false), + reconnect: Reconnect, }), z.object({ transport: z.const('streamable-http'), @@ -117,6 +123,7 @@ export const Config = z.union([ headers: z.dict(String).default({}), toolCallTimeoutMs: z.number().default(DEFAULT_TOOL_CALL_TIMEOUT_MS), failOnStartupError: z.boolean().default(false), + reconnect: Reconnect, }), ]) as unknown as z @@ -131,7 +138,12 @@ export const Config = z.union([ * @returns startup readiness after connection and initial tool discovery settle. */ export async function apply(ctx: Context, config: Config): Promise { - // Reserve the namespace first: a duplicate `serverName` fails THIS instance + // Fail loud at load: reconnect misconfiguration (including programmatic + // construction that bypassed Schemastery) rejects THIS instance before any + // effect registers. + const reconnect = resolveReconnectPolicy(config.reconnect, `mcp-client(${config.serverName}): reconnect`) + + // Reserve the namespace next: a duplicate `serverName` fails THIS instance // at load with an actionable error and leaves the earlier instance intact. ctx.effect(() => { let names = activeServerNames.get(ctx.root) @@ -148,58 +160,22 @@ export async function apply(ctx: Context, config: Config): Promise { return () => void names.delete(config.serverName) }, 'mcp-client.serverName') - const transport = createTransport(config) - const client = new Client( - { name: 'dsh-mcp-client', version: '0.0.1' }, - { capabilities: {} }, - ) + // The supervisor owns the client/transport generations, the reconnect + // loop, and the live tool registrations; disposal stops reconnection, + // quiesces in-flight work, and unregisters the current generation. + const connection = startConnection(ctx, config, reconnect) - const opts = { - registrationFailure: 'contain' as const, - serverName: config.serverName, - toolCallTimeoutMs: config.toolCallTimeoutMs, - } - - // Connect and set up tools. `ready` always settles to an outcome so rollback - // can close a partially opened client even when strict startup later rejects. - // Its accessor returns the CURRENT disposer generation, so disposal always - // unregisters the live set, not the first one. - const ready = (async () => { - await client.connect(transport) - - let disposers = await syncTools(client, ctx, { - ...opts, - registrationFailure: config.failOnStartupError ? 'throw' : 'contain', - }, new Map()) - - client.setNotificationHandler( - ToolListChangedNotificationSchema, - async () => { - ctx.logger.info(`mcp-client(${config.serverName}): tool list changed, re-syncing`) - try { - disposers = await syncTools(client, ctx, opts, disposers) - } catch (error) { - // Fetch-phase failure: the previous generation is still registered - // and `disposers` still owns it — keep serving the last good list. - ctx.logger.error(`mcp-client(${config.serverName}): tool re-sync failed: ${String(error)}`) - } - }, - ) - - return { getDisposers: () => disposers } - })().catch((error: unknown) => { - ctx.logger.error(`mcp-client(${config.serverName}): startup failed: ${String(error)}`) - return { getDisposers: () => new Map void>(), error } - }) - - ctx.effect(() => async () => { - const outcome = await ready - for (const dispose of outcome.getDisposers().values()) dispose() - try { await client.close() } catch { /* transport already gone */ } + ctx.effect(() => { + return () => connection.dispose() }, 'mcp-client.connection') - const outcome = await ready - if ('error' in outcome && config.failOnStartupError) { + // Block plugin activation on the initial connection + tool discovery so + // Cordis consumers observe the tools immediately after the fiber activates. + // When failOnStartupError is true, a failed initial attempt rejects the + // fiber (Cordis rolls it back); otherwise the error is logged and the + // supervisor enters its reconnect loop. + const outcome = await connection.ready + if (outcome.error !== undefined && config.failOnStartupError) { throw new Error(`mcp-client(${config.serverName}): initial connection or tool synchronization failed`, { cause: outcome.error }) } } diff --git a/packages/mcp/mcp-client/src/invariant.ts b/packages/mcp/mcp-client/src/invariant.ts index e2d8ac22cb..6532d8c2ef 100644 --- a/packages/mcp/mcp-client/src/invariant.ts +++ b/packages/mcp/mcp-client/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-mcp-client' diff --git a/packages/mcp/mcp-client/src/tools.ts b/packages/mcp/mcp-client/src/tools.ts index 889684923b..92862fa15f 100644 --- a/packages/mcp/mcp-client/src/tools.ts +++ b/packages/mcp/mcp-client/src/tools.ts @@ -16,7 +16,7 @@ import { createHash } from 'node:crypto' import type { Client } from '@modelcontextprotocol/sdk/client/index.js' import { ListToolsResultSchema } from '@modelcontextprotocol/sdk/types.js' import { z } from 'zod' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ToolDefinition, ToolExecution } from '@deepseek-ai/dsh-tools' import { assertSupportedJsonSchema } from '@deepseek-ai/dsh-tools' import type { JsonSchemaNode, JsonValue } from '@deepseek-ai/dsh-tools' diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index e30a1ee716..d4f301be54 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -3,7 +3,7 @@ * Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites. */ import { describe, expect, it, vi, beforeEach } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import type { Config } from '@deepseek-ai/dsh-mcp-client' @@ -124,6 +124,33 @@ describe('mcp-client plugin module exports', () => { } as never) expect(resolved.serverName).toBe('github-prod_1') }) + + it('Config schema materializes reconnect defaults and merges partial overrides', () => { + const omitted = ConfigSchema({ + transport: 'stdio', + serverName: 'srv', + command: 'echo', + } as never) + expect(omitted.reconnect).toEqual({ enabled: true, initialDelayMs: 500, maxDelayMs: 30_000, maxAttempts: 10 }) + + const partial = ConfigSchema({ + transport: 'stdio', + serverName: 'srv', + command: 'echo', + reconnect: { initialDelayMs: 100 }, + } as never) + expect(partial.reconnect).toEqual({ enabled: true, initialDelayMs: 100, maxDelayMs: 30_000, maxAttempts: 10 }) + }) + + it('Config schema rejects an invalid reconnect block', () => { + // schemastery unions wrap branch errors, so assert the throw only. + expect(() => ConfigSchema({ + transport: 'stdio', + serverName: 'srv', + command: 'echo', + reconnect: { maxAttempts: 0 }, + } as never)).toThrow() + }) }) describe('apply (plugin lifecycle)', () => { @@ -132,7 +159,10 @@ describe('apply (plugin lifecycle)', () => { beforeEach(async () => { vi.clearAllMocks() mockConnect.mockResolvedValue(undefined) - mockClose.mockResolvedValue(undefined) + mockClose.mockImplementation(function (this: { onclose?: () => void }) { + this.onclose?.() + return Promise.resolve() + }) mockListTools.mockResolvedValue({ tools: [{ name: 'remote', description: 'A remote tool', inputSchema: { type: 'object' } }], nextCursor: undefined, @@ -216,19 +246,23 @@ describe('apply (plugin lifecycle)', () => { expect(mockListTools).not.toHaveBeenCalled() expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() - // Disposal exercises the empty fallback accessor: nothing to unregister, - // close still attempted, no throw. + // Disposal cancels the scheduled reconnect attempt: nothing to + // unregister, close already attempted by the failed attempt, no throw. await ctx.fiber.dispose() await sleep(50) expect(mockClose).toHaveBeenCalled() }) it('rejects activation and still closes the client when startup failure is configured as fatal', async () => { - mockConnect.mockRejectedValue(new Error('connection refused')) + const cause = new Error('connection refused') + mockConnect.mockRejectedValue(cause) await expect(apply(ctx, { ...stdioConfig, failOnStartupError: true, - })).rejects.toThrow('initial connection or tool synchronization failed') + })).rejects.toMatchObject({ + message: 'mcp-client(srv): initial connection or tool synchronization failed', + cause, + }) expect(mockListTools).not.toHaveBeenCalled() expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() @@ -258,6 +292,32 @@ describe('apply (plugin lifecycle)', () => { expect(mockClose).toHaveBeenCalled() }) + it('preserves strict startup registration when list_changed arrives before connect resolves', async () => { + ctx.tools.register({ + name: 'mcp__srv__remote', + description: 'Foreign squatter', + parameters: { type: 'object' }, + output: { + schema: { type: 'string' }, + render: (_args, value) => [{ type: 'text', text: value as string }], + }, + execute: async () => 'foreign', + }) + mockConnect.mockImplementation(async () => { + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + await handler() + }) + + await expect(apply(ctx, { + ...stdioConfig, + failOnStartupError: true, + })).rejects.toThrow('initial connection or tool synchronization failed') + + expect(mockListTools).toHaveBeenCalledTimes(2) + expect(ctx.tools.get('mcp__srv__remote')?.description).toBe('Foreign squatter') + await ctx.fiber.dispose() + }) + it('re-syncs tools on ToolListChanged notification', async () => { await apply(ctx, stdioConfig) @@ -311,7 +371,10 @@ describe('apply (plugin lifecycle)', () => { }) it('effect disposer handles client.close failure gracefully', async () => { - mockClose.mockRejectedValue(new Error('already closed')) + mockClose.mockImplementation(function (this: { onclose?: () => void }) { + this.onclose?.() + return Promise.reject(new Error('already closed')) + }) await apply(ctx, stdioConfig) diff --git a/packages/mcp/mcp-client/tests/fixture-server.ts b/packages/mcp/mcp-client/tests/fixture-server.ts index 8491b96634..974e2a26b0 100644 --- a/packages/mcp/mcp-client/tests/fixture-server.ts +++ b/packages/mcp/mcp-client/tests/fixture-server.ts @@ -51,6 +51,17 @@ server.registerTool('image', { ], })) +server.registerTool('crash', { + title: 'Crash Tool', + description: 'Replies, then exits the server process (crash-recovery test).', + inputSchema: {}, +}, async () => { + // Exit AFTER the response flushes so the caller observes a clean result + // followed by a transport close, like a real post-reply crash. + setTimeout(() => process.exit(7), 25) + return { content: [{ type: 'text', text: 'crashing' }] } +}) + // Dotted name: legal in MCP, illegal in the DeepSeek function-name contract. // Exercises the bridge's normalize-and-hash public-name path end to end. server.registerTool('admin.reset', { diff --git a/packages/mcp/mcp-client/tests/load-path.spec.ts b/packages/mcp/mcp-client/tests/load-path.spec.ts index 5507cd5b83..89d6ce1f2a 100644 --- a/packages/mcp/mcp-client/tests/load-path.spec.ts +++ b/packages/mcp/mcp-client/tests/load-path.spec.ts @@ -11,7 +11,7 @@ */ import { describe, expect, it } from 'vitest' -import Loader from '@cordisjs/plugin-loader' +import Loader from '@deepseek-ai/cordis-plugin-loader' import * as mcpClient from '@deepseek-ai/dsh-mcp-client' describe('dsh-mcp-client real-load-path guard', () => { diff --git a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index e1f51d20e9..34d0970258 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -13,8 +13,8 @@ import { mkdtemp, rm, writeFile, readFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' -import { afterAll, beforeAll, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' +import { Context } from '@deepseek-ai/cordis' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' import { z } from 'zod' @@ -200,6 +200,87 @@ describe('fixture server — disposal', () => { }, 30_000) }) +describe('fixture server — crash recovery', () => { + function crashConfig(serverName: string, reconnect: NonNullable): Config { + return { + transport: 'stdio', + serverName, + command: process.execPath, + args: [fixtureServerPath], + env: {}, + cwd: packageDir, + toolCallTimeoutMs: 15_000, + failOnStartupError: false, + reconnect, + } + } + + it('auto-reconnects after a stdio crash and serves tool calls again', async () => { + const ctx = await mountRegistry() + await apply(ctx, crashConfig('crashy', { initialDelayMs: 50, maxDelayMs: 500, maxAttempts: 40 })) + + const before = await ctx.tools.execute({ + signal: testToolSignal, + callId: nextCallId(), name: 'mcp__crashy__add', arguments: { a: 2, b: 3 }, + }) + expect(textOf(before.content[0])).toBe('5') + + // The crash tool replies, then kills the real child process. + const crash = await ctx.tools.execute({ + signal: testToolSignal, + callId: nextCallId(), name: 'mcp__crashy__crash', arguments: {}, + }) + expect(crash.isError).toBe(false) + + // Recovery is proven by the world: a post-crash call round-trips through + // the respawned server process. + await vi.waitFor(async () => { + const after = await ctx.tools.execute({ + signal: testToolSignal, + callId: nextCallId(), name: 'mcp__crashy__add', arguments: { a: 20, b: 22 }, + }) + expect(after.isError).toBe(false) + expect(textOf(after.content[0])).toBe('42') + }, { timeout: 15_000, interval: 250 }) + + // The recovered generation replaced the dead one: no duplicates, no leak. + const addEntries = ctx.tools.schemas().map(s => s.name).filter(name => name === 'mcp__crashy__add') + expect(addEntries).toHaveLength(1) + + await ctx.fiber.dispose() + await sleep(200) + }, 30_000) + + it('plugin unload during an outage stops reconnection and unregisters tools', async () => { + const ctx = await mountRegistry() + const fiber = ctx.plugin( + { name: 'mcp-client', inject: ['tools'], apply }, + crashConfig('ephemeral', { initialDelayMs: 8_000, maxDelayMs: 8_000, maxAttempts: 5 }), + ) + // Cordis awaits async apply() as startup work; wait for it. + await vi.waitFor(() => { expect(ctx.tools.get('mcp__ephemeral__add')).toBeDefined() }, { timeout: 20_000 }) + + const crash = await ctx.tools.execute({ + signal: testToolSignal, + callId: nextCallId(), name: 'mcp__ephemeral__crash', arguments: {}, + }) + expect(crash.isError).toBe(false) + + // Give the transport close a moment to land the supervisor in its 8s + // backoff wait, then unload: disposal must not sit out the backoff. + await sleep(300) + const started = Date.now() + await fiber.dispose() + expect(Date.now() - started).toBeLessThan(4_000) + + expect(ctx.tools.get('mcp__ephemeral__add')).toBeUndefined() + await sleep(200) + expect(ctx.tools.get('mcp__ephemeral__add')).toBeUndefined() + + await ctx.fiber.dispose() + }, 30_000) +}) + // ---- @modelcontextprotocol/server-everything ---- describe('server-everything — official test server', () => { diff --git a/packages/mcp/mcp-client/tests/mcp-client.spec.ts b/packages/mcp/mcp-client/tests/mcp-client.spec.ts index 22559d74cf..aa97ba34ce 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.spec.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi, beforeEach } from 'vitest' import { Client } from '@modelcontextprotocol/sdk/client/index.js' import { InMemoryTransport } from '@modelcontextprotocol/sdk/inMemory.js' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type JsonValue } from '@deepseek-ai/dsh-tools' diff --git a/packages/mcp/mcp-client/tests/reconnect.spec.ts b/packages/mcp/mcp-client/tests/reconnect.spec.ts new file mode 100644 index 0000000000..bdbae26a7e --- /dev/null +++ b/packages/mcp/mcp-client/tests/reconnect.spec.ts @@ -0,0 +1,521 @@ +/** + * Tests for the mcp-client connection supervisor: crash-driven reconnection + * with bounded backoff, generation-safe tool re-registration, the failure + * cap, the stability-window budget reset, and disposal stopping reconnection. + * Isolated file so vi.mock of the MCP SDK doesn't pollute other test suites. + */ +import { describe, expect, it, vi, beforeEach } from 'vitest' +import { Context } from '@deepseek-ai/cordis' +import SystemPrompt from '@deepseek-ai/dsh-system-prompt' +import ToolRegistry from '@deepseek-ai/dsh-tools' +import { CallId } from '@deepseek-ai/dsh-llm' +import type { Config } from '@deepseek-ai/dsh-mcp-client' + +// ---- Mock MCP SDK ---- + +// vi.mock factories are hoisted above every import/const, so the mock fns and +// class must be created inside vi.hoisted to exist when the factories run. +const { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient, instances } = vi.hoisted(() => { + const mockConnect = vi.fn<() => Promise>() + const mockClose = vi.fn<() => Promise>() + const mockListTools = vi.fn<(_params?: Record) => Promise>() + const mockCallTool = vi.fn<( + _params?: Record, _compatibilitySchema?: unknown, _options?: unknown, + ) => Promise>() + const mockSetNotificationHandler = vi.fn() + const mockRequest = vi.fn(async ( + request: { method: string; params?: Record }, + _schema: unknown, + options?: unknown, + ): Promise => { + if (request.method === 'tools/list') return await mockListTools(request.params) + if (request.method === 'tools/call') return await mockCallTool(request.params, undefined, options) + throw new Error(`unexpected MCP request: ${request.method}`) + }) + class MockClient { + onclose: (() => void) | undefined + connect = mockConnect + close = mockClose + request = mockRequest + setNotificationHandler = mockSetNotificationHandler + constructor() { instances.push(this) } + } + const instances: MockClient[] = [] + return { mockConnect, mockClose, mockListTools, mockCallTool, mockSetNotificationHandler, MockClient, instances } +}) + +vi.mock('@modelcontextprotocol/sdk/client/index.js', () => ({ + Client: MockClient, +})) + +vi.mock('@modelcontextprotocol/sdk/client/stdio.js', () => ({ + StdioClientTransport: vi.fn(), +})) + +vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ + StreamableHTTPClientTransport: vi.fn(), +})) + +// vi.mock is hoisted above static imports, so the modules under test see the +// mocked SDK even through a static import. +import { apply } from '@deepseek-ai/dsh-mcp-client/src/index.ts' +import { RECONNECT_DEFAULTS, resolveReconnectPolicy, startConnection } from '@deepseek-ai/dsh-mcp-client/src/connection.ts' + +// ---- Helpers ---- + +const testToolSignal = new AbortController().signal + +async function mountRegistry(): Promise { + const ctx = new Context() + await ctx.plugin(SystemPrompt) + await ctx.plugin(ToolRegistry) + return ctx +} + +function sleep(ms: number): Promise { + // Annotated binding (not withResolvers()): the tests lint layer runs + // no-invalid-void-type with default options, which rejects the explicit + // type argument in call position but accepts the inferred form. + const gate: PromiseWithResolvers = Promise.withResolvers() + setTimeout(gate.resolve, ms) + return gate.promise +} + +/** Capture the supervisor's logger lines by level on one context. */ +function captureLogs(ctx: Context): { warns: string[]; errors: string[]; infos: string[] } { + const warns: string[] = [] + const errors: string[] = [] + const infos: string[] = [] + ctx.logger.warn = ((message: unknown) => { warns.push(String(message)) }) as typeof ctx.logger.warn + ctx.logger.error = ((message: unknown) => { errors.push(String(message)) }) as typeof ctx.logger.error + ctx.logger.info = ((message: unknown) => { infos.push(String(message)) }) as typeof ctx.logger.info + return { warns, errors, infos } +} + +function stdioConfig(reconnect?: Config['reconnect']): Config { + return { + transport: 'stdio', + serverName: 'srv', + command: 'echo', + args: [], + env: {}, + cwd: '', + toolCallTimeoutMs: 60_000, + failOnStartupError: false, + ...reconnect === undefined ? {} : { reconnect }, + } +} + +/** The tool list the mock server advertises after a successful (re)connect. */ +function listing(...names: string[]): { tools: { name: string; inputSchema: { type: string } }[]; nextCursor: undefined } { + return { + tools: names.map(name => ({ name, inputSchema: { type: 'object' } })), + nextCursor: undefined, + } +} + +let callSeq = 0 +function nextCallId(): CallId { + return CallId(`reconnect-${++callSeq}`) +} + +// ---- Tests ---- + +describe('reconnect supervisor', () => { + let ctx: Context + + beforeEach(async () => { + vi.clearAllMocks() + instances.length = 0 + mockConnect.mockResolvedValue(undefined) + mockClose.mockImplementation(function (this: { onclose?: () => void }) { + this.onclose?.() + return Promise.resolve() + }) + mockListTools.mockResolvedValue(listing('remote')) + mockCallTool.mockResolvedValue({ content: [{ type: 'text', text: 'ok' }] }) + ctx = await mountRegistry() + }) + + it('reconnects after a transport close, re-syncs tools through the new generation, and serves calls', async () => { + const { warns, infos } = captureLogs(ctx) + await apply(ctx, stdioConfig({ initialDelayMs: 5, maxDelayMs: 40, maxAttempts: 5 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + expect(instances).toHaveLength(1) + + // The recovered server advertises a different list: the swap must neither + // duplicate nor leak the pre-crash generation. + mockListTools.mockResolvedValue(listing('revived')) + instances[0]!.onclose?.() + + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__revived')).toBeDefined() }) + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(instances).toHaveLength(2) + expect(mockConnect).toHaveBeenCalledTimes(2) + + // Post-recovery calls execute through the re-registered definition. + const result = await ctx.tools.execute({ + signal: testToolSignal, + callId: nextCallId(), name: 'mcp__srv__revived', arguments: {}, + }) + expect(result.isError).toBe(false) + + // User-visible state: reconnecting and recovered are distinct lines. + expect(warns.some(line => line.includes('reconnecting in 5ms (attempt 1/5)'))).toBe(true) + expect(infos.some(line => line.includes('reconnected and re-synced tools'))).toBe(true) + + // A late close signal from the replaced generation is ignored. + instances[0]!.onclose?.() + await sleep(30) + expect(instances).toHaveLength(2) + }) + + it('stops at the failure cap, unregisters the tools, and reports final failure', async () => { + const { warns, errors } = captureLogs(ctx) + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + mockConnect.mockRejectedValue(new Error('server gone')) + // A failing close on the failed attempt's cleanup must not break the loop. + mockClose.mockImplementation(function (this: { onclose?: () => void }) { + this.onclose?.() + return Promise.reject(new Error('already closed')) + }) + instances[0]!.onclose?.() + + await vi.waitFor(() => { + expect(errors.some(line => line.includes('giving up after 2 consecutive failed reconnect attempts'))).toBe(true) + }) + // Stale tools do not leak past final failure. + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + // Initial connect + exactly maxAttempts reconnect attempts. + expect(mockConnect).toHaveBeenCalledTimes(3) + expect(warns.some(line => line.includes('connection attempt failed: Error: server gone'))).toBe(true) + expect(warns.some(line => line.includes('connection failed; retrying in 4ms (attempt 2/2)'))).toBe(true) + await sleep(30) + expect(mockConnect).toHaveBeenCalledTimes(3) + }) + + it('gives up behind an in-flight re-sync and removes the generation it publishes', async () => { + const { errors } = captureLogs(ctx) + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 1 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + const gate: PromiseWithResolvers = Promise.withResolvers() + mockListTools.mockImplementation(() => gate.promise) + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + const resync = handler() + await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) }) + + mockConnect.mockRejectedValue(new Error('server gone')) + instances[0]!.onclose?.() + await vi.waitFor(() => { + expect(errors.some(line => line.includes('giving up after 1 consecutive failed reconnect attempts'))).toBe(true) + }) + + gate.resolve(listing('late')) + await resync + await vi.waitFor(() => { + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(ctx.tools.get('mcp__srv__late')).toBeUndefined() + }) + expect(mockConnect).toHaveBeenCalledTimes(2) + }) + + it('does not start a replacement until a failed generation reports that it closed', async () => { + const { warns } = captureLogs(ctx) + mockConnect.mockRejectedValueOnce(new Error('initialize failed')) + // Model the SDK's fire-and-forget close after initialize fails: the + // harness's second close call returns, but the child has not exited yet. + mockClose.mockResolvedValue(undefined) + + const applying = apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 })) + await vi.waitFor(() => { expect(mockClose).toHaveBeenCalled() }) + await sleep(30) + expect(instances).toHaveLength(1) + + instances[0]!.onclose?.() + await applying + await vi.waitFor(() => { expect(instances).toHaveLength(2) }) + expect(warns.some(line => line.includes('connection failed; retrying in 2ms (attempt 1/2)'))).toBe(true) + }) + + it('stops reconnecting when a failed generation never reports that it closed', async () => { + vi.useFakeTimers() + try { + const { errors } = captureLogs(ctx) + mockConnect.mockRejectedValue(new Error('initialize failed')) + mockClose.mockResolvedValue(undefined) + + const applying = apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 })) + await vi.advanceTimersByTimeAsync(5_000) + await applying + + expect(instances).toHaveLength(1) + expect(errors.some(line => line.includes('reconnect stopped to avoid overlapping server processes'))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('suppresses retry reporting when disposal owns a pending connect rejection', async () => { + const { warns } = captureLogs(ctx) + const gate: PromiseWithResolvers = Promise.withResolvers() + mockConnect.mockImplementation(() => gate.promise) + const handle = startConnection(ctx, stdioConfig(), resolveReconnectPolicy(undefined, 'reconnect')) + await vi.waitFor(() => { expect(instances).toHaveLength(1) }) + + const disposing = handle.dispose() + gate.reject(new Error('disposed connect')) + await disposing + await handle.ready + + expect(warns.some(line => line.includes('connection attempt failed'))).toBe(false) + expect(instances).toHaveLength(1) + }) + + it('bounds disposal while a resolving generation never reports that it closed', async () => { + vi.useFakeTimers() + try { + const { errors } = captureLogs(ctx) + const gate: PromiseWithResolvers = Promise.withResolvers() + mockConnect.mockImplementation(() => gate.promise) + mockClose.mockResolvedValue(undefined) + const handle = startConnection(ctx, stdioConfig(), resolveReconnectPolicy(undefined, 'reconnect')) + await vi.advanceTimersByTimeAsync(0) + + const disposing = handle.dispose() + await vi.advanceTimersByTimeAsync(5_000) + gate.resolve() + await disposing + + expect(mockListTools).not.toHaveBeenCalled() + expect(errors.some(line => line.includes('server shutdown may be incomplete'))).toBe(true) + } finally { + vi.useRealTimers() + } + }) + + it('dispose during the backoff wait cancels the pending reconnect', async () => { + await apply(ctx, stdioConfig({ initialDelayMs: 60_000, maxDelayMs: 60_000, maxAttempts: 5 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + instances[0]!.onclose?.() + // Now waiting out a 60s backoff; disposal must return promptly anyway. + await ctx.fiber.dispose() + await sleep(30) + expect(mockConnect).toHaveBeenCalledTimes(1) + expect(instances).toHaveLength(1) + }) + + it('a transport close after dispose schedules nothing', async () => { + const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig()) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + await fiber.dispose() + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + + // The disposer's client.close() fires onclose in the real SDK. + instances[0]!.onclose?.() + await sleep(30) + expect(instances).toHaveLength(1) + expect(mockConnect).toHaveBeenCalledTimes(1) + }) + + it('reconnect disabled keeps the registered tools and reports manual recovery', async () => { + const { errors } = captureLogs(ctx) + await apply(ctx, stdioConfig({ enabled: false })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + instances[0]!.onclose?.() + await sleep(30) + expect(mockConnect).toHaveBeenCalledTimes(1) + // Pre-reconnect contract: the generation stays registered until disposal. + expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() + expect(errors.some(line => line.includes('connection lost and reconnect is disabled'))).toBe(true) + }) + it('reconnect disabled after a failed initial connect reports no registered tools', async () => { + const { errors } = captureLogs(ctx) + mockConnect.mockRejectedValue(new Error('refused')) + await apply(ctx, stdioConfig({ enabled: false })) + await sleep(30) + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(errors.some(line => line.includes('connection failed and reconnect is disabled'))).toBe(true) + expect(errors.some(line => line.includes('no tools were registered'))).toBe(true) + }) + + it('an uptime past the stability window resets the attempt budget', async () => { + const { errors } = captureLogs(ctx) + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 30, maxAttempts: 1 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + instances[0]!.onclose?.() + await vi.waitFor(() => { expect(instances).toHaveLength(2) }) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + // Outlive the stability window (= maxDelayMs), then crash again: the + // budget restarts at attempt 1 instead of exceeding maxAttempts. + await sleep(40) + instances[1]!.onclose?.() + await vi.waitFor(() => { expect(instances).toHaveLength(3) }) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + expect(errors).toHaveLength(0) + }) + + it('a crash loop with briefly successful connects still exhausts the cap', async () => { + const { errors } = captureLogs(ctx) + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 10_000, maxAttempts: 1 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + // Crash, recover (attempt 1 of 1), crash again well inside the stability + // window: the successful connect must not launder the budget. + instances[0]!.onclose?.() + await vi.waitFor(() => { expect(instances).toHaveLength(2) }) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + instances[1]!.onclose?.() + + await vi.waitFor(() => { + expect(errors.some(line => line.includes('giving up after 1 consecutive failed reconnect attempts'))).toBe(true) + }) + expect(instances).toHaveLength(2) + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + }) + + it('a connect rejection racing its own transport close schedules exactly one retry per attempt', async () => { + const { errors } = captureLogs(ctx) + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 3 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + // Each reconnect attempt sees the stdio transport die (onclose) AND its + // connect() reject — the real SDK emits both for a spawn failure. + mockConnect.mockImplementation(async () => { + instances.at(-1)!.onclose?.() + throw new Error('spawn failed') + }) + instances[0]!.onclose?.() + + await vi.waitFor(() => { + expect(errors.some(line => line.includes('giving up after 3 consecutive failed reconnect attempts'))).toBe(true) + }) + // Initial generation + exactly one generation per budgeted attempt: a + // double-scheduled retry would create more. + expect(instances).toHaveLength(4) + expect(errors.filter(line => line.includes('giving up')).length).toBe(1) + }) + + it('a transport that closes during a resolving connect registers nothing from the dead generation', async () => { + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 2 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + expect(mockListTools).toHaveBeenCalledTimes(1) + + mockConnect.mockImplementation(async () => { + instances.at(-1)!.onclose?.() + }) + instances[0]!.onclose?.() + + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() }) + // The dead generations never reached tool discovery. + expect(mockListTools).toHaveBeenCalledTimes(1) + }) + + it('dispose during an in-flight initial sync quiesces without leaking tools', async () => { + const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 5 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + // Block the reconnect attempt's tool discovery until after dispose starts. + const gate: PromiseWithResolvers = Promise.withResolvers() + mockListTools.mockImplementation(() => gate.promise) + instances[0]!.onclose?.() + await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) }) + + const disposing = fiber.dispose() + await sleep(10) + gate.resolve(listing('late')) + await disposing + + // The late sync's swap ran, then disposal unregistered its result: no + // generation survives the plugin. + expect(ctx.tools.get('mcp__srv__remote')).toBeUndefined() + expect(ctx.tools.get('mcp__srv__late')).toBeUndefined() + }) + + it('a re-sync failing because dispose closed the transport stays silent', async () => { + const { errors } = captureLogs(ctx) + const fiber = ctx.plugin({ name: 'mcp-client', inject: ['tools'], apply }, stdioConfig()) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + const gate: PromiseWithResolvers = Promise.withResolvers() + mockListTools.mockImplementation(() => gate.promise) + const handler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + const resync = handler() + await vi.waitFor(() => { expect(mockListTools).toHaveBeenCalledTimes(2) }) + + const disposing = fiber.dispose() + await sleep(10) + gate.reject(new Error('Connection closed')) + await disposing + await resync + + expect(errors.some(line => line.includes('tool re-sync failed'))).toBe(false) + }) + + it('a stale notification handler from a replaced generation is ignored', async () => { + await apply(ctx, stdioConfig({ initialDelayMs: 2, maxDelayMs: 8, maxAttempts: 5 })) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + + instances[0]!.onclose?.() + await vi.waitFor(() => { expect(instances).toHaveLength(2) }) + await vi.waitFor(() => { expect(ctx.tools.get('mcp__srv__remote')).toBeDefined() }) + const listCalls = mockListTools.mock.calls.length + + const staleHandler = mockSetNotificationHandler.mock.calls[0]![1] as () => Promise + await staleHandler() + expect(mockListTools).toHaveBeenCalledTimes(listCalls) + }) +}) + +// ---- Policy resolution ---- + +describe('resolveReconnectPolicy', () => { + const path = 'mcp-client(srv): reconnect' + + it('resolves omission to the defaults, frozen', () => { + const policy = resolveReconnectPolicy(undefined, path) + expect(policy).toEqual(RECONNECT_DEFAULTS) + expect(Object.isFrozen(policy)).toBe(true) + }) + + it('keeps explicit values', () => { + expect(resolveReconnectPolicy( + { enabled: false, initialDelayMs: 1, maxDelayMs: 2, maxAttempts: 7 }, + path, + )).toEqual({ enabled: false, initialDelayMs: 1, maxDelayMs: 2, maxAttempts: 7 }) + }) + + it('rejects unknown keys', () => { + expect(() => resolveReconnectPolicy({ jitterRatio: 0.5 } as never, path)) + .toThrow(/reconnect\.jitterRatio is not a reconnect option/) + }) + + it('rejects out-of-range delays', () => { + expect(() => resolveReconnectPolicy({ initialDelayMs: 0 }, path)).toThrow(/initialDelayMs must be a positive finite number/) + expect(() => resolveReconnectPolicy({ initialDelayMs: Number.POSITIVE_INFINITY }, path)).toThrow(/initialDelayMs/) + expect(() => resolveReconnectPolicy({ maxDelayMs: -1 }, path)).toThrow(/maxDelayMs must be a positive finite number/) + }) + + it('rejects an initial delay above the ceiling', () => { + expect(() => resolveReconnectPolicy({ initialDelayMs: 100, maxDelayMs: 5 }, path)) + .toThrow(/initialDelayMs must be less than or equal to maxDelayMs/) + }) + + it('rejects non-positive-integer attempt caps', () => { + expect(() => resolveReconnectPolicy({ maxAttempts: 0 }, path)).toThrow(/maxAttempts must be a positive integer/) + expect(() => resolveReconnectPolicy({ maxAttempts: 1.5 }, path)).toThrow(/maxAttempts must be a positive integer/) + }) + + it('apply fails loud at load on a misconfigured reconnect', async () => { + const ctx = await mountRegistry() + await expect(apply(ctx, stdioConfig({ initialDelayMs: 100, maxDelayMs: 5 }))) + .rejects.toThrow(/initialDelayMs must be less than or equal to maxDelayMs/) + }) +}) diff --git a/packages/mcp/mcp-client/tsconfig.json b/packages/mcp/mcp-client/tsconfig.json index 461b250297..f42f4d4c2c 100644 --- a/packages/mcp/mcp-client/tsconfig.json +++ b/packages/mcp/mcp-client/tsconfig.json @@ -26,6 +26,9 @@ }, { "path": "../../support/invariants" + }, + { + "path": "../../util/timeout" } ] } diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index a810981357..33b536d3ee 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-plan-mode", "description": "Logged per-agent plan mode with deployment guidance, a direct slash command, and a user-reviewed exit", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/plan/plan-mode" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -34,16 +41,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-commands": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-projection": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-user-interaction": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-interaction": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-commands": { @@ -65,6 +72,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/plan/plan-mode/src/index.ts b/packages/plan/plan-mode/src/index.ts index 86da4d4935..53cea83f5c 100644 --- a/packages/plan/plan-mode/src/index.ts +++ b/packages/plan/plan-mode/src/index.ts @@ -23,7 +23,7 @@ * @module @deepseek-ai/dsh-plan-mode */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { z as zod } from 'zod' import type { ZodType } from 'zod' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' @@ -54,7 +54,7 @@ declare module '@deepseek-ai/dsh-session/types' { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { planMode: PlanModeService } diff --git a/packages/plan/plan-mode/src/invariant.ts b/packages/plan/plan-mode/src/invariant.ts index 797010c59f..634efadfb5 100644 --- a/packages/plan/plan-mode/src/invariant.ts +++ b/packages/plan/plan-mode/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned durable plan-mode invariants. @module @deepseek-ai/dsh-plan-mode/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/plan/plan-mode/tests/integration.spec.ts b/packages/plan/plan-mode/tests/integration.spec.ts index 34678714fa..f061f12034 100644 --- a/packages/plan/plan-mode/tests/integration.spec.ts +++ b/packages/plan/plan-mode/tests/integration.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService, { createUserMessage, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/plan/plan-mode/tests/invariant.spec.ts b/packages/plan/plan-mode/tests/invariant.spec.ts index 7ebee4cdb7..7cab457b6e 100644 --- a/packages/plan/plan-mode/tests/invariant.spec.ts +++ b/packages/plan/plan-mode/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as PlanModeInvariant from '@deepseek-ai/dsh-plan-mode/invariant' import InvariantService from '@deepseek-ai/dsh-invariants' diff --git a/packages/plan/plan-mode/tests/plan-mode.spec.ts b/packages/plan/plan-mode/tests/plan-mode.spec.ts index fddee83915..529dde6879 100644 --- a/packages/plan/plan-mode/tests/plan-mode.spec.ts +++ b/packages/plan/plan-mode/tests/plan-mode.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { RUN_CODE_NAME, defineContentToolFixture } from '@deepseek-ai/dsh-tools' diff --git a/packages/plan/plan-mode/tests/projection.spec.ts b/packages/plan/plan-mode/tests/projection.spec.ts index b1e546d8b7..b9557c0c7e 100644 --- a/packages/plan/plan-mode/tests/projection.spec.ts +++ b/packages/plan/plan-mode/tests/projection.spec.ts @@ -10,7 +10,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/preset/agent-presets/README.i18n.yaml b/packages/preset/agent-presets/README.i18n.yaml index ecd2a5a6c0..2465865bff 100644 --- a/packages/preset/agent-presets/README.i18n.yaml +++ b/packages/preset/agent-presets/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/preset/agent-presets/README.md -README.md: 632fc7828313a932512cb59a924050005067a008 -README.zh.md: 41dfab1af81149a221cb333a5613ab0a2d899899 +README.md: 98891c8710adc7d72dee20a8466742ee6f649956 +README.zh.md: 0fcc5fa4d697affc2185b9251c6a60dee6510042 diff --git a/packages/preset/agent-presets/README.md b/packages/preset/agent-presets/README.md index 632fc78283..98891c8710 100644 --- a/packages/preset/agent-presets/README.md +++ b/packages/preset/agent-presets/README.md @@ -73,7 +73,7 @@ A preset may publish display text in an optional `preset.yml` beside its composi ```yaml name: 极简模式 -description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 +description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 ``` It carries display text ONLY. `id` is the directory name and `trust` comes from the root the preset was discovered under, so neither is writable here — otherwise a locally authored preset could name itself into the shipped set. It is a separate file because the composition is a top-level list of plugin rows: YAML cannot carry sibling keys beside it, and a fake metadata row would hand the Loader something to load. @@ -133,7 +133,8 @@ Prefix-stable for the life of an agent: a composition is installed once, before ## Known Limitations and Deferred Work - **A preset cannot be changed once a session has produced anything** — `recompose` re-links a BLANK session's parent scope to another standing mount, and only a blank one: switching a composition that already ran would strand tools the model has called. Changing the default affects only sessions created afterwards. -- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. Sessions already joined keep their generation, and nothing reclaims a superseded one while the process lives (bounded by how often compositions are edited, not by sessions). +- **A generation is keyed on the composition file alone** — the stamp check notices `agent.cordis.yml` changing, not an edit to a skill file or asset beside it; those reach new sessions only once the composition file itself moves or the process restarts. +- **A superseded generation is never reclaimed** — sessions already joined keep the generation they run on, and the roster holds no join count that could tell when the last one left, so the whole subtree stays mounted until the process ends. The cost is per generation rather than per session, but it is not free: `dsh-skill-local` watches its roots by default, so each edit-then-create cycle adds a live watcher set. Bounded by how often compositions are edited — which the settings-page authoring flow makes a per-save event rather than a per-deploy one. Reclaiming one needs a joined-agent count on the standing mount; see the `TODO` at `ensureStanding`. - **A copy is never mounted to validate** — it is byte-identical to its source, so a source broken on disk yields a copy exactly as broken as the source; discovery's health check marks both rows on the next roster read rather than deferring the failure to a session start. - **Health is a shape check, not a mount** — discovery proves the composition parses in the loader dialect and holds named rows, not that every row's module resolves or activates; a row naming an absent package still fails at the first session, which rolls the creation back. - **A copy is a snapshot that drifts** — upgrading the deployment does not update copies of shipped presets, and there is no patch semantics at this layer to express "standard plus one change" (that is the bundle layer's `cordis.patch.yml`); the shipped set itself accepts the same cost — `cordis` and `code` are full copies of `standard` — so the whole assembly stays readable in one file. diff --git a/packages/preset/agent-presets/README.zh.md b/packages/preset/agent-presets/README.zh.md index 41dfab1af8..0fcc5fa4d6 100644 --- a/packages/preset/agent-presets/README.zh.md +++ b/packages/preset/agent-presets/README.zh.md @@ -73,7 +73,7 @@ preset 可以在组装文件旁的可选 `preset.yml` 里发布展示文本: ```yaml name: 极简模式 -description: 仅提供 bash 与 str_replace_editor 的双工具编码 Agent,用于基准测试和最小复现。 +description: 仅提供持久 bash 与 str_replace_editor 的双工具编码 Agent。 ``` 它**只**承载展示文本。`id` 是目录名,`trust` 取自 preset 被发现时所在的根目录,两者都不可写在这里——否则本地创作的 preset 就能把自己命名进随附集合。之所以是独立文件:组装是插件行的顶层列表,YAML 无法在其旁携带同级键,而伪造一个元信息行等于递给 Loader 一个要加载的东西。 @@ -133,7 +133,8 @@ Indirectly, through the plugins a standing composition registers, which own ever ## Known Limitations and Deferred Work - **会话一旦产出内容便无法更换 preset** —— `recompose` 把**空白**会话的父作用域重链到另一个常驻挂载,且仅限空白会话:切换已运行过的组装会抽走模型已调用的工具。更改默认值只影响此后创建的会话。 -- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。已加入的会话保持其代际,进程存活期间不回收被替代的代际(上限取决于组装被编辑的频率,而非会话数)。 +- **代际只以组装文件为键** —— stamp 检查只察觉 `agent.cordis.yml` 的变化,察觉不到旁边 skill 文件或资产的编辑;那些编辑要等组装文件本身变动或进程重启才达到新会话。 +- **被替代的代际永不回收** —— 已加入的会话保持其运行所在的代际,而名单没有加入计数可以判断最后一个何时离开,因此整棵子树一直挂到进程结束。代价按代际计而非按会话计,但并非为零:`dsh-skill-local` 默认监听自己的根目录,因此每一轮「编辑后建会话」都会新增一套活的 watcher。上限取决于组装被编辑的频率——而设置页的编写流程把这件事从「每次部署」变成了「每次保存」。要回收就需要给常驻挂载加上已加入 agent 的计数;见 `ensureStanding` 处的 `TODO`。 - **副本从不被实际挂载以校验** —— 它与来源逐字节相同,因此磁盘上已坏的来源会产出与来源同样损坏的副本;发现过程的健康检查会在下一次读取名单时把两行都标出来,而不是把失败推迟到会话启动。 - **健康是形状检查,不是挂载** —— 发现过程只证明组装能以加载器方言解析、由具名行组成,不证明每一行的模块都能解析并激活;引用不存在的包的行仍在第一个会话处失败,并回滚该会话的创建。 - **副本是会漂移的快照** —— 升级部署不会更新随附 preset 的副本,本层也没有表达「standard 加一处改动」的 patch 语义(那是 bundle 层 `cordis.patch.yml` 的能力);随附集合自己也接受同样的代价——`cordis` 与 `code` 就是 `standard` 的完整副本——换来整份组装在一个文件里可读。 diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index c012dc3eda..f744bf2965 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/preset/agent-presets" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,23 +32,25 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-atomic-write": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-settings": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "js-yaml": "^4.1.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-atomic-write": "workspace:^", @@ -54,6 +63,6 @@ "@deepseek-ai/dsh-settings-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/preset/agent-presets/src/discovery.ts b/packages/preset/agent-presets/src/discovery.ts index de8312893a..9e91bc85a2 100644 --- a/packages/preset/agent-presets/src/discovery.ts +++ b/packages/preset/agent-presets/src/discovery.ts @@ -17,7 +17,7 @@ import { readdir, readFile, stat } from 'node:fs/promises' import { join, resolve } from 'node:path' import { load } from 'js-yaml' -import { entryListSchema } from '@cordisjs/plugin-include' +import { entryListSchema } from '@deepseek-ai/cordis-plugin-include' import { expandHomePath } from '@deepseek-ai/dsh-paths' import { readPresetMetadata } from './metadata.ts' import { PRESET_ID, type AgentPreset, type PresetRoot } from './types.ts' diff --git a/packages/preset/agent-presets/src/index.ts b/packages/preset/agent-presets/src/index.ts index 1dde902234..bddab50676 100644 --- a/packages/preset/agent-presets/src/index.ts +++ b/packages/preset/agent-presets/src/index.ts @@ -22,9 +22,11 @@ */ import { stat } from 'node:fs/promises' -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { bindScopeParent, createScope, scopeOf, type Scope, type ScopeKey, type ScopeParentBinding } from '@deepseek-ai/dsh-scope' +// Type-only: resolves the `agent/created` lifecycle event this service watches. +import type {} from '@deepseek-ai/dsh-agent' import { settingsNamespace, type SettingsScope, type default as SettingsService } from '@deepseek-ai/dsh-settings' import { discoverPresets } from './discovery.ts' import { copyComposition, deleteComposition, readComposition } from './authoring.ts' @@ -62,7 +64,7 @@ export { resolveSessionPreset, type PresetBearingSession } from './session.ts' export { PresetMountError, UnknownPresetError } from './types.ts' export type { AgentPreset, Config, PresetRoot, PresetTrust } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { agentPresets: AgentPresets } @@ -130,6 +132,28 @@ export class AgentPresets extends Service { this.settingsService = undefined }, 'agentPresets.settings()') }) + + // Advisory, not fatal: a synchronous `agent/created` listener that throws + // VETOES publication, and this service must not, because composing an agent + // outside the roster is legal — `recompose` binds exactly such a bare agent + // below, and the ACP, SDK-server, and headless entry points all create one. + // The invariant companion is the check that fails loud, at assembly. Why an + // unjoined agent matters at all has one home: the [Agent + // Note](../../../../.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md). + // + // Known false positive: a session created bare and bound later by + // `recompose` is warned about once, before its first bind. No shipped flow + // does that today — the Web surface mounts in `setup` and children join + // through `composeFrom` before publication. + ctx.on('agent/created', ({ agent }) => { + if (this.config.roots.length === 0) return + if (this.composedPreset(agent.ctx) !== undefined) return + ctx.logger.warn( + `agent "${agent.id}" was published without joining an agent preset; ` + + 'its tools, prompt sections, and skill catalog resolve against the empty global layer ' + + '(join through AgentPresets.mount() or composeFrom() in the agent factory setup)', + ) + }) } /** @@ -440,6 +464,12 @@ export class AgentPresets extends Service { // disappearing, and failing the session over a stat would not. const current = await compositionStamp(preset.path) if (current === undefined || sameStamp(mounted.stamp, current)) return mounted + // TODO: reclaim the superseded generation once the last agent joined to + // it is gone. The subtree is not inert — `dsh-skill-local` watches its + // roots — and the settings-page authoring flow turns "a composition + // changed" into a per-save event. This needs a joined-agent count on + // StandingMount, incremented in `mount`/`composeFrom`/`recompose` and + // decremented when the agent's scope key dies. // Guarded delete: a caller that raced this one may have already started // the next generation, and dropping THAT pointer would fork a third. if (this.standing.get(preset.id) === pending) this.standing.delete(preset.id) diff --git a/packages/preset/agent-presets/src/invariant.ts b/packages/preset/agent-presets/src/invariant.ts index 7a08eab7f1..e9240a0b2d 100644 --- a/packages/preset/agent-presets/src/invariant.ts +++ b/packages/preset/agent-presets/src/invariant.ts @@ -3,8 +3,12 @@ * @module @deepseek-ai/dsh-agent-presets/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' +// Type-only: resolves the `system-prompt/assemble` waterfall this companion +// joins, and the `agent` field `dsh-agent` merges into its context. +import type {} from '@deepseek-ai/dsh-system-prompt' +import type {} from '@deepseek-ai/dsh-agent' // Imported through the package name, not `./mount.ts`: a module shared between // the two build entry points becomes a third chunk that the published `files` // list does not carry, which `verify-built-package-invariants` rejects. @@ -18,9 +22,10 @@ export const name = 'agent-presets-invariant' export const inject = ['invariants'] /** - * Assert that no installed preset composition reaches the root service realm. + * Assert that no installed preset composition reaches the root service realm, + * and that a deployment configuring a roster composes every agent from it. * - * `mountPreset` proves this once, when the subtree settles. A row that + * `mountPreset` proves the first once, when the subtree settles. A row that * publishes later — from a timer, or an asynchronous continuation after its * plugin returned — would escape that one-shot audit, so re-check every live * mount whenever a service registration changes. @@ -37,6 +42,33 @@ const install: InvariantInstaller = (ctx, fail) => { ) } }, { global: true }) + + // An agent that joined no preset resolves `tools`, `system-prompt`, and + // `skill` against the empty global layer, so the model receives nothing. + // `composedPreset()` is the roster's own answer to "did this agent join", + // read from the live scope chain — see the [Agent + // Note](../../../../.agents/notes/implemented/architecture/2026-08-10-host-plane-ownership-after-presets.md) + // for why the warning beside it is advisory while this one fails. + // + // Two conditions, each load-bearing. `context.agent` is what makes this an + // AGENT assembly: a scope-only assembly — a cold read resolving presenters + // in a standing key, a diagnostic — is not an agent and must not be judged + // on whether it joined anything. And assembly rather than publication is the + // moment that matters, because an unjoined agent is legal until it addresses + // a model: `recompose` binds a bare agent as its first link, and that agent + // is unjoined for its whole life up to the switch. + ctx.on('system-prompt/assemble', (_assembly, context, next) => { + const presets = ctx.get('agentPresets') + const agent = context.agent + if (presets !== undefined && presets.config.roots.length > 0 + && agent !== undefined && presets.composedPreset(agent.ctx) === undefined) { + fail( + `agent "${agent.id}" addressed a model without joining any agent preset while a roster is ` + + 'composed; its tools, prompt sections, and skill catalog resolve against the empty global layer', + ) + } + return next() + }) } /** diff --git a/packages/preset/agent-presets/src/mount.ts b/packages/preset/agent-presets/src/mount.ts index e968a97c25..d78603b749 100644 --- a/packages/preset/agent-presets/src/mount.ts +++ b/packages/preset/agent-presets/src/mount.ts @@ -16,9 +16,9 @@ import { isAbsolute } from 'node:path' import { pathToFileURL } from 'node:url' -import { Context, type Fiber } from 'cordis' -import { Include } from '@cordisjs/plugin-include' -import type { EntryTree } from '@cordisjs/plugin-loader' +import { Context, type Fiber } from '@deepseek-ai/cordis' +import { Include } from '@deepseek-ai/cordis-plugin-include' +import type { EntryTree } from '@deepseek-ai/cordis-plugin-loader' import { scopeOf, scopeParentOf, type ScopeKey } from '@deepseek-ai/dsh-scope' import { PresetMountError, type AgentPreset } from './types.ts' diff --git a/packages/preset/agent-presets/tests/authoring.spec.ts b/packages/preset/agent-presets/tests/authoring.spec.ts index b6f4ef388a..df69a792d5 100644 --- a/packages/preset/agent-presets/tests/authoring.spec.ts +++ b/packages/preset/agent-presets/tests/authoring.spec.ts @@ -11,9 +11,9 @@ import { existsSync } from 'node:fs' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { beforeEach, describe, expect, it } from 'vitest' import AgentPresets, { COMPOSITION_FILE, copyComposition, METADATA_FILE, diff --git a/packages/preset/agent-presets/tests/invariant.spec.ts b/packages/preset/agent-presets/tests/invariant.spec.ts index 17d89813c3..709ee5ba00 100644 --- a/packages/preset/agent-presets/tests/invariant.spec.ts +++ b/packages/preset/agent-presets/tests/invariant.spec.ts @@ -1,13 +1,13 @@ import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' -import AgentRegistry from '@deepseek-ai/dsh-agent' +import AgentRegistry, { assembleContextFor } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import InvariantService from '@deepseek-ai/dsh-invariants' import { describe, expect, it } from 'vitest' @@ -84,4 +84,34 @@ describe('agent-presets invariants', () => { setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'isolated'), })).resolves.toBeDefined() }) + + it('rejects an agent that addresses a model without joining any preset', async () => { + const ctx = await harness() + // The delegation shape: an agent composed outside the roster joined no + // standing mount, so every registry view it reads is the empty global + // layer. Publication alone stays legal — `recompose` binds exactly such an + // agent — so nothing fires until that empty world reaches a prompt. + const handle = await ctx.agents.create({ sessionId: SessionId('inv-unjoined') }) + + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))) + .rejects.toThrow(/without joining any agent preset/) + }) + + it('admits a joined agent, a scopeless read, and a standing-key read', async () => { + const ctx = await harness() + const handle = await ctx.agents.create({ + sessionId: SessionId('inv-joined'), + setup: async (agentCtx: Context) => void await ctx.agentPresets.mount(agentCtx, 'standard'), + }) + + await expect(ctx.systemPrompt.assemble(assembleContextFor(handle.agent))).resolves.toBeDefined() + // A scopeless assembly belongs to no agent, so it cannot be an unjoined one. + await expect(ctx.systemPrompt.assemble({})).resolves.toBeDefined() + // Neither can a scope that is not an agent at all: a standing preset key + // has no parent of its own, so a chain-length rule would reject the cold + // read that resolves presenters in it. `context.agent` is what keeps this + // check to agent assemblies. + const standing = await ctx.agentPresets.standingKeyFor('standard') + await expect(ctx.systemPrompt.assemble({ scope: standing })).resolves.toBeDefined() + }) }) diff --git a/packages/preset/agent-presets/tests/mount.spec.ts b/packages/preset/agent-presets/tests/mount.spec.ts index afe297a406..9f2760e704 100644 --- a/packages/preset/agent-presets/tests/mount.spec.ts +++ b/packages/preset/agent-presets/tests/mount.spec.ts @@ -2,9 +2,9 @@ import { mkdir, mkdtemp, readFile, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' @@ -18,7 +18,7 @@ import AgentPresets, { import type { Config } from '@deepseek-ai/dsh-agent-presets' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Published by the `isolated` fixture preset behind an entry-local realm. */ fixtureIsolatedSvc: { label: string } @@ -499,6 +499,35 @@ describe('replacing a composition', () => { expect(toolNames(ctx, handle.agent)).toEqual(['alpha']) }) + it('names an agent that was published without joining any preset', async () => { + const ctx = await harness() + const warnings: string[] = [] + ctx.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof ctx.logger.warn + + await ctx.agents.create({ sessionId: SessionId('sess-unjoined-warn') }) + // Advisory, not fatal: a synchronous `agent/created` throw would veto + // publication, and creating an agent outside the roster stays legal. + expect(warnings.filter(line => line.includes('sess-unjoined-warn'))).toHaveLength(1) + expect(warnings.at(-1)).toMatch(/without joining an agent preset/) + + warnings.length = 0 + await agentOn(ctx, 'sess-joined-quiet', 'minimal') + expect(warnings).toEqual([]) + }) + + it('says nothing when the deployment configures no roster at all', async () => { + // Presets are optional: every surface except the Web bundle keeps its + // model-facing rows in the host plane, so an agent with a chain of one is + // exactly right there and the diagnostic must stay silent. + const rosterless = await harness({ default: 'standard', roots: [] }) + const warnings: string[] = [] + rosterless.logger.warn = ((message: unknown) => { warnings.push(String(message)) }) as typeof rosterless.logger.warn + + await rosterless.agents.create({ sessionId: SessionId('sess-no-roster') }) + + expect(warnings).toEqual([]) + }) + it('composes an agent that had nothing installed', async () => { // An agent created without a preset has no binding to re-link, so the // switch is its first bind — exactly a mount — and once bound only the diff --git a/packages/preset/agent-presets/tests/settings.spec.ts b/packages/preset/agent-presets/tests/settings.spec.ts index 081ffceba4..ef75eb8b78 100644 --- a/packages/preset/agent-presets/tests/settings.spec.ts +++ b/packages/preset/agent-presets/tests/settings.spec.ts @@ -8,9 +8,9 @@ import { mkdir, mkdtemp, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/preset/agent-presets/tsconfig.json b/packages/preset/agent-presets/tsconfig.json index 47d5577207..a12c5a0149 100644 --- a/packages/preset/agent-presets/tsconfig.json +++ b/packages/preset/agent-presets/tsconfig.json @@ -18,12 +18,18 @@ { "path": "../../../vendor/include" }, + { + "path": "../../core/agent" + }, { "path": "../../core/scope" }, { "path": "../../core/session" }, + { + "path": "../../core/system-prompt" + }, { "path": "../../settings/settings" }, diff --git a/packages/preset/persona/README.i18n.yaml b/packages/preset/persona/README.i18n.yaml index c4573b49f8..f40850a6c9 100644 --- a/packages/preset/persona/README.i18n.yaml +++ b/packages/preset/persona/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/preset/persona/README.md -README.md: 789776b32d907f7d217accccbca5508f88de0ed1 -README.zh.md: 4e28d75bbd4fd22b77a0fa3b18c5f19df08588d8 +README.md: 742141e65fa8d50b89e6b74e6d21aa8c5bfe98cd +README.zh.md: add106adb5b81e45d8c6929a9a0f98b5c0072a01 diff --git a/packages/preset/persona/README.md b/packages/preset/persona/README.md index 789776b32d..742141e65f 100644 --- a/packages/preset/persona/README.md +++ b/packages/preset/persona/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -The agent persona as a composable row. One config field, one prompt section. +The agent persona as a composable row. It can either shadow the deployment persona or own the complete system prompt. [`dsh-system-prompt`](../../core/system-prompt/README.md) owns the deployment persona as its own config and registers that section unconditionally, so a process has exactly one. An [agent preset](../agent-presets/README.md) cannot mount the prompt registry itself — without a row of its own, a preset could change an agent's tools but never its identity. This package is that row. @@ -15,8 +15,9 @@ Mounting this row outside an agent scope collides with the registry's own `deplo | Field | Default | Meaning | |---|---|---| | `text` | required | Persona prose rendered as the `deployment:persona` section | +| `complete` | `false` | Restore this persona after assembly as the only system-prompt section | -`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render. +`text` is a template, like any prompt section: complete `{{…}}` groups resolve strictly against registered prompt variables when the prompt renders, not when it assembles. Empty text still occupies the slot, so it shadows the deployment persona away entirely and then disappears at render. With `complete: true`, assembly still resolves contexts, tools, variables, and cooperative listeners, then the prompt registry restores this exact persona as the sole section; no identity, tool guidance, or listener can append prompt text. ## Model Experience @@ -24,11 +25,11 @@ Mounting this row outside an agent scope collides with the registry's own `deplo #### What the model sees -The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured. +The `deployment:persona` section at order 0, immediately after the harness identity opener, carrying exactly this row's configured `text` with prompt variables resolved. For an agent whose preset mounts this row, it replaces whatever persona the deployment configured. In complete mode, the model sees only this rendered section as its system prompt. #### Token effect -Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing. +Fixed for a given preset: the persona's own tokens on every request that agent makes, and none for any other agent. Empty text contributes nothing. Complete mode removes every other system-prompt token for that agent. #### KV Cache effect diff --git a/packages/preset/persona/README.zh.md b/packages/preset/persona/README.zh.md index 4e28d75bbd..add106adb5 100644 --- a/packages/preset/persona/README.zh.md +++ b/packages/preset/persona/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -把 agent(智能体)人设做成一个可组装的行:一个配置字段,一个提示词段落。 +把 agent(智能体)人设做成一个可组装的行:它既可以遮蔽部署级人设,也可以拥有完整系统提示词。 [`dsh-system-prompt`](../../core/system-prompt/README.md) 以自身配置持有部署级人设,并且无条件注册该段落,因此一个进程只有一份。[agent preset](../agent-presets/README.md) 无法自行挂载提示词注册表——若没有属于自己的行,preset 能改变 agent 的工具,却永远改不了它的身份。本包就是那一行。 @@ -15,8 +15,9 @@ | 字段 | 默认值 | 含义 | |---|---|---| | `text` | 必填 | 作为 `deployment:persona` 段落渲染的人设文本 | +| `complete` | `false` | 组装后将此人设恢复为唯一的系统提示词段落 | -`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。 +`text` 与任何提示词段落一样是模板:完整的 `{{…}}` 组在提示词**渲染**时(而非组装时)严格解析为已注册的提示词变量。空文本同样占据该槽位,因此会把部署级人设整个遮蔽掉,然后在渲染时消失。启用 `complete: true` 时,组装仍会解析上下文、工具、变量和协作式监听器,之后提示词注册表将这份确切人设恢复为唯一段落;身份、工具引导或监听器都无法追加提示词文本。 ## Model Experience @@ -24,11 +25,11 @@ #### What the model sees -位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。 +位于 order 0 的 `deployment:persona` 段落,紧随 harness 身份开场白之后,携带本行配置的 `text`,其中的提示词变量已解析。对于其 preset 挂载了本行的 agent,它会替换部署所配置的任何人设。在完整模式下,模型只会看到这个渲染后的段落作为系统提示词。 #### Token effect -对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。 +对给定 preset 而言是固定的:该 agent 的每次请求都携带人设自身的 token,其他 agent 一个都不带。空文本不贡献任何 token。完整模式会移除该 agent 的其他所有系统提示词 token。 #### KV Cache effect diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 5ec7678d16..7a9c4cd746 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/preset/persona" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/preset/persona/src/index.ts b/packages/preset/persona/src/index.ts index ec56bcc780..69a53678e9 100644 --- a/packages/preset/persona/src/index.ts +++ b/packages/preset/persona/src/index.ts @@ -13,8 +13,8 @@ * @module @deepseek-ai/dsh-persona */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-system-prompt' // Imported rather than restated: the registry declares the slot this row @@ -38,23 +38,27 @@ export interface Config { * variables. Empty text drops the section at render, matching the registry. */ text: string + /** Make this persona the complete system prompt, suppressing every other section. */ + complete?: boolean } /** Runtime schema for the persona row. */ export const Config: z = z.object({ text: z.string().required(), + complete: z.boolean().default(false), }) /** * Register the persona section for the mounting context's scope. * @param ctx - an agent scope context; an unscoped context collides with the * prompt registry's own persona registration and rejects. - * @param config - the persona text. + * @param config - the persona text and complete-prompt policy. */ export function apply(ctx: Context, config: Config): void { ctx.effect(() => ctx.systemPrompt.section({ name: PERSONA_SECTION, order: PERSONA_ORDER, text: config.text, + ...(config.complete ? { complete: true } : {}), }), 'persona.section()') } diff --git a/packages/preset/persona/src/invariant.ts b/packages/preset/persona/src/invariant.ts index 5f9068fe24..ee63503de3 100644 --- a/packages/preset/persona/src/invariant.ts +++ b/packages/preset/persona/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-persona' @@ -16,7 +16,8 @@ export const inject = ['invariants'] /** * No runtime invariant: this row owns no event stream or mutable runtime data — it registers one - * prompt section and the prompt registry owns section identity, shadowing, and disposal. + * prompt section and the prompt registry owns identity, complete-prompt enforcement, shadowing, + * and disposal. */ const install: InvariantInstaller = () => {} diff --git a/packages/preset/persona/tests/persona.spec.ts b/packages/preset/persona/tests/persona.spec.ts index bb7555df7c..3fcc30d3a5 100644 --- a/packages/preset/persona/tests/persona.spec.ts +++ b/packages/preset/persona/tests/persona.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt, { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { createScope, type ScopeKey } from '@deepseek-ai/dsh-scope' import { describe, expect, it } from 'vitest' @@ -85,4 +85,21 @@ describe('the persona row', () => { expect(renderPrompt(await ctx.systemPrompt.assemble({ scope: key }))) .toContain('You run on deepseek-v4-pro.') }) + + it('makes a complete persona the exact prompt after every other contribution', async () => { + const ctx = await harness('deployment identity') + const key: ScopeKey = { agent: 'a1' } + const scope = createScope(ctx, key) + ctx.systemPrompt.section({ name: 'global:extra', order: 100, text: 'global guidance' }) + + await scope.ctx.plugin(Persona, { text: 'Only this.', complete: true }) + scope.ctx.on('system-prompt/assemble', async (assembly, _context, next) => { + assembly.sections.push({ name: 'late:extra', text: 'late guidance' }) + return next() + }, { prepend: true }) + + const assembly = await ctx.systemPrompt.assemble({ scope: key }) + expect(assembly.sections).toEqual([{ name: PERSONA_SECTION, text: 'Only this.' }]) + expect(renderPrompt(assembly)).toBe('Only this.') + }) }) diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index 94f98c02d5..3db9e83889 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-pty-local", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/pty/pty-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-pty": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -46,6 +53,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/pty/pty-local/src/config.ts b/packages/pty/pty-local/src/config.ts index 41733c0959..be9ae3eed3 100644 --- a/packages/pty/pty-local/src/config.ts +++ b/packages/pty/pty-local/src/config.ts @@ -1,6 +1,6 @@ /** Validated configuration for the local PTY backend. */ -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' /** Public plugin configuration. */ export interface Config { diff --git a/packages/pty/pty-local/src/index.ts b/packages/pty/pty-local/src/index.ts index fa2237c959..ee48a5821d 100644 --- a/packages/pty/pty-local/src/index.ts +++ b/packages/pty/pty-local/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-pty-local */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import { PtyBackendCleanupError } from '@deepseek-ai/dsh-pty' diff --git a/packages/pty/pty-local/src/invariant.ts b/packages/pty/pty-local/src/invariant.ts index b54ac50f63..174fab273a 100644 --- a/packages/pty/pty-local/src/invariant.ts +++ b/packages/pty/pty-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-pty-local' diff --git a/packages/pty/pty-local/tests/index.spec.ts b/packages/pty/pty-local/tests/index.spec.ts index dd3a956536..fc07fc784c 100644 --- a/packages/pty/pty-local/tests/index.spec.ts +++ b/packages/pty/pty-local/tests/index.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' import { PassThrough } from 'node:stream' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox, type Agent } from '@deepseek-ai/dsh-agent' import SandboxProvider from '@deepseek-ai/dsh-sandbox' diff --git a/packages/pty/pty-local/tests/local.spec.ts b/packages/pty/pty-local/tests/local.spec.ts index 31ffaf7e3f..f23a6f077a 100644 --- a/packages/pty/pty-local/tests/local.spec.ts +++ b/packages/pty/pty-local/tests/local.spec.ts @@ -2,7 +2,7 @@ import { existsSync, mkdtempSync, readFileSync, realpathSync, rmSync } from 'nod import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/pty/pty/package.json b/packages/pty/pty/package.json index 702426a2b7..37837181fb 100644 --- a/packages/pty/pty/package.json +++ b/packages/pty/pty/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-pty", "description": "Persistent PTY session seam for the DeepSeek Harness — owner-scoped ids, backend registry, interactive sends, reads, signals, and awaited cleanup", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/pty/pty" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/pty/pty/src/index.ts b/packages/pty/pty/src/index.ts index 8ff4168e1a..fb28107238 100644 --- a/packages/pty/pty/src/index.ts +++ b/packages/pty/pty/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-pty */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { PtyBackendCleanupError } from './types.ts' import type { @@ -45,7 +45,7 @@ export { PtyBackendCleanupError } from './types.ts' /** Opaque identity minted by {@link PtyService} for one live PTY session. */ export type PtySessionId = PtySessionIdValue -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { pty: PtyService } diff --git a/packages/pty/pty/src/invariant.ts b/packages/pty/pty/src/invariant.ts index 9395d2164c..ae8b0110f0 100644 --- a/packages/pty/pty/src/invariant.ts +++ b/packages/pty/pty/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-pty' diff --git a/packages/pty/pty/tests/service.spec.ts b/packages/pty/pty/tests/service.spec.ts index 0301de837a..02dc004ae3 100644 --- a/packages/pty/pty/tests/service.spec.ts +++ b/packages/pty/pty/tests/service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/pty/tool-bash-persistent/package.json b/packages/pty/tool-bash-persistent/package.json index 23aad9f32e..0074f050b1 100644 --- a/packages/pty/tool-bash-persistent/package.json +++ b/packages/pty/tool-bash-persistent/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-bash-persistent", "description": "Model-facing owner-scoped persistent Bash tool backed by the Harness PTY service", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/pty/tool-bash-persistent" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -24,19 +31,19 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-pty": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -49,6 +56,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/pty/tool-bash-persistent/src/index.ts b/packages/pty/tool-bash-persistent/src/index.ts index 24cc998cf2..fa2a965231 100644 --- a/packages/pty/tool-bash-persistent/src/index.ts +++ b/packages/pty/tool-bash-persistent/src/index.ts @@ -4,8 +4,8 @@ */ import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { PtyReadResult, PtySendResult, PtySessionId } from '@deepseek-ai/dsh-pty' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' diff --git a/packages/pty/tool-bash-persistent/src/invariant.ts b/packages/pty/tool-bash-persistent/src/invariant.ts index 5e276d4c45..b06c59764c 100644 --- a/packages/pty/tool-bash-persistent/src/invariant.ts +++ b/packages/pty/tool-bash-persistent/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-bash-persistent' diff --git a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts index a69048e8d6..90cc6b7422 100644 --- a/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } 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 Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' diff --git a/packages/pty/tool-bash-persistent/tests/tools.spec.ts b/packages/pty/tool-bash-persistent/tests/tools.spec.ts index 28a950eacf..d43fd64399 100644 --- a/packages/pty/tool-bash-persistent/tests/tools.spec.ts +++ b/packages/pty/tool-bash-persistent/tests/tools.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' diff --git a/packages/pty/tool-pty/package.json b/packages/pty/tool-pty/package.json index f443bbbdf4..804f6fb1a4 100644 --- a/packages/pty/tool-pty/package.json +++ b/packages/pty/tool-pty/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-pty", "description": "Six model-facing persistent PTY tools with owner isolation and generic background-task integration", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/pty/tool-pty" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,22 +32,22 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-pty": "^0.0.1", - "@deepseek-ai/dsh-retention": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tasks": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-pty": "workspace:^", + "@deepseek-ai/dsh-retention": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -56,6 +63,6 @@ "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/pty/tool-pty/src/index.ts b/packages/pty/tool-pty/src/index.ts index fc66d2646e..f6dc2fc42c 100644 --- a/packages/pty/tool-pty/src/index.ts +++ b/packages/pty/tool-pty/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-tool-pty */ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { PtySessionId } from '@deepseek-ai/dsh-pty' diff --git a/packages/pty/tool-pty/src/invariant.ts b/packages/pty/tool-pty/src/invariant.ts index f8451af962..05c6fbf9d9 100644 --- a/packages/pty/tool-pty/src/invariant.ts +++ b/packages/pty/tool-pty/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-pty' diff --git a/packages/pty/tool-pty/tests/loader-composition.spec.ts b/packages/pty/tool-pty/tests/loader-composition.spec.ts index 35d0ea5d0a..ff19707d1e 100644 --- a/packages/pty/tool-pty/tests/loader-composition.spec.ts +++ b/packages/pty/tool-pty/tests/loader-composition.spec.ts @@ -3,9 +3,9 @@ import { tmpdir } from 'node:os' import { join } 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 Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' diff --git a/packages/pty/tool-pty/tests/tools.spec.ts b/packages/pty/tool-pty/tests/tools.spec.ts index 235aad0e02..042c620b29 100644 --- a/packages/pty/tool-pty/tests/tools.spec.ts +++ b/packages/pty/tool-pty/tests/tools.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' diff --git a/packages/sandbox/sandbox-local/README.i18n.yaml b/packages/sandbox/sandbox-local/README.i18n.yaml index ba2e21b8e9..e8f0500876 100644 --- a/packages/sandbox/sandbox-local/README.i18n.yaml +++ b/packages/sandbox/sandbox-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/sandbox/sandbox-local/README.md -README.md: 4d9e8275ba3fe0c1f49555b61e319f52194244bc -README.zh.md: 8a755e6c5b0c266538277bbbcd118fd24ab164f3 +README.md: e43133c7c5b64d7779162b790ee6cab7806fd100 +README.zh.md: 32743d1b0aba5bed41ee53d90c4ed44dc936161c diff --git a/packages/sandbox/sandbox-local/README.md b/packages/sandbox/sandbox-local/README.md index 4d9e8275ba..e43133c7c5 100644 --- a/packages/sandbox/sandbox-local/README.md +++ b/packages/sandbox/sandbox-local/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt. Multiple candidates are probed in order, while a sole candidate is selected directly. +Local implementation of the [`dsh-sandbox`](../sandbox/) seam. It selects and caches one platform runner: Linux prefers a working `bwrap` then Landlock; macOS uses Seatbelt; Windows uses the ACL restricted-token runner. Multiple candidates are probed in order, while a sole candidate is selected directly. The package root exports the default and named `LocalSandboxProvider` plugin and `Config`; platform profile builders stay internal. @@ -12,6 +12,8 @@ Policy is per call; the provider stores only the mechanism and cached runner ver The Seatbelt profile is allow-default with `(deny file-write*)` plus write allow-lists, so exactly the mode's promised file effects are governed: `read-only` grants the `/dev/null` literal alone; `workspace-write` adds the workspace root, `/tmp`, and the per-user darwin temp dir (`os.tmpdir()` — the platform's real temp area for mkstemp-family tools), every root canonicalized because Seatbelt matches resolved paths (`/tmp` IS `/private/tmp`). Apple marks the `sandbox-exec` CLI deprecated but ships it on every macOS; the functional probe is what fails closed if that ever changes. +The Windows rung keeps one deterministic write SID and standing ACE per workspace, but gives every live session/workspace pair a random private temp directory with a distinct SID and revocable ACE. Sessions sharing a workspace therefore share its intended write authority without inheriting one another's temp authority. A fresh provider always chooses a new temp path and SID, so crash residue cannot block or authorize a resumed session; agentless calls receive the same per-invocation isolation from the runner. A workspace equal to or containing the platform temp root fails before any ACL mutation because its inheritable workspace ACE would otherwise reach every private temp child. + [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run) supplies the platform launcher, functional probe, and CLI argument vocabulary. This provider owns only mode-to-grant mapping and runner selection. Keeping path resolution and probe parsing with the versioned binary prevents contract drift. ```yaml @@ -31,7 +33,7 @@ No direct invalidation; the named consumer owns any request-prefix changes. ## Known Limitations and Deferred Work -- **Windows has no runner** — `win32` fails closed with `SANDBOX_UNAVAILABLE`; an AppContainer-family backend is deferred. +- **Windows ACL enforcement is partial** — the restricted token must retain Everyone for process initialization, so external objects granting Everyone write access remain writable; NTFS hard links also alias one file object across workspace and external paths. The provider reports `enforcement: 'partial'` rather than overstating that boundary as full. - **Landlock may be partial** — older supported kernel ABIs confine only the access classes they expose, reported as `enforcement: 'partial'` rather than overstated as full. - **Seatbelt depends on deprecated `sandbox-exec`** — macOS still ships it, but this provider cannot replace or probe that private policy engine if Apple removes it. - **Runner selection is cached for the provider lifetime** — installing, removing, or repairing a runner requires reloading the plugin before selection changes. diff --git a/packages/sandbox/sandbox-local/README.zh.md b/packages/sandbox/sandbox-local/README.zh.md index 8a755e6c5b..32743d1b0a 100644 --- a/packages/sandbox/sandbox-local/README.zh.md +++ b/packages/sandbox/sandbox-local/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -[`dsh-sandbox`](../sandbox/) seam 的本地实现。它选择并缓存一个平台 runner:Linux 优先选择可工作的 `bwrap`,否则选择 Landlock;macOS 使用 Seatbelt。多个候选项会按顺序探测,只有一个候选项时则直接选择。 +[`dsh-sandbox`](../sandbox/) seam 的本地实现。它选择并缓存一个平台 runner:Linux 优先选择可工作的 `bwrap`,否则选择 Landlock;macOS 使用 Seatbelt;Windows 使用 ACL 受限令牌 runner。多个候选项会按顺序探测,只有一个候选项时则直接选择。 包根目录导出默认及命名的 `LocalSandboxProvider` 插件和 `Config`;平台 profile builder 仍为内部实现。 @@ -12,6 +12,8 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list,因此恰好约束相应模式承诺的文件操作:`read-only` 只授予 `/dev/null` 字面路径;`workspace-write` 另加工作区根目录、`/tmp` 和逐用户 darwin 临时目录(`os.tmpdir()`,即平台供 mkstemp 家族工具使用的真实临时区域)。每个根目录都经过规范化,因为 Seatbelt 匹配解析后的路径(`/tmp` 就是 `/private/tmp`)。Apple 将 `sandbox-exec` CLI(命令行界面)标为 deprecated,但所有 macOS 系统仍会提供它;若情况发生变化,功能探测会使执行被拒绝。 +Windows 档为每个工作区保留一个确定性写入 SID 和常驻 ACE,但为每个活跃的会话/工作区对分配一个随机私有临时目录,以及不同的 SID 和可回收 ACE。因此,共享工作区的会话会共享预期的写权限,却不会继承彼此的临时目录权限。新的提供方总会选择新的临时路径和 SID,因此崩溃残留既无法阻止恢复的会话,也无法向其授权;runner 会为无 agent(智能体)的调用提供同样的逐调用隔离。如果工作区等于或包含平台临时根目录,调用会在任何 ACL 改动发生前失败,因为否则其可继承的工作区 ACE 会延伸到每个私有临时子目录。 + [`@deepseek-ai/node-addon-landlock-run`](https://www.npmjs.com/package/@deepseek-ai/node-addon-landlock-run)提供平台 launcher、功能探测和 CLI 参数词汇。该提供方只负责模式到授权的映射与 runner 选择。把路径解析和探测解析保留在带版本的 binary 中,可防止约定漂移。 ```yaml @@ -31,7 +33,7 @@ Seatbelt profile 默认允许,但带 `(deny file-write*)` 和写入 allow-list ## 已知限制与暂缓事项 -- **Windows 没有 runner**:`win32` 以 `SANDBOX_UNAVAILABLE` 拒绝执行;AppContainer 家族后端暂缓实现。 +- **Windows ACL 只能实现部分强制执行**:受限令牌必须保留 Everyone 以完成进程初始化,因此授予 Everyone 写访问的外部对象仍可写;NTFS 硬链接也会使工作区路径与外部路径指向同一个文件对象。提供方报告 `enforcement: 'partial'`,而不会把该边界夸大为完整强制执行。 - **Landlock 可能只实现部分强制执行**:较旧且受支持的内核 ABI 只能限制自身公开的访问类别,因此报告 `enforcement: 'partial'`,不会夸大为完整强制执行。 - **Seatbelt 依赖已弃用的 `sandbox-exec`**:macOS 仍会提供它,但若 Apple 移除该私有策略引擎,该提供方无法替换或探测。 - **runner 选择在提供方生命周期内缓存**:安装、移除或修复 runner 后,必须重载插件才能改变选择。 diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 226c241204..64fdcb8991 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-sandbox-local", "description": "Local process-sandbox backends for the DeepSeek Harness sandbox seam: bwrap, the npm-distributed landlock-run launcher, macOS Seatbelt, or the Windows ACL restricted-token runner — functionally probed, fail-closed", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/sandbox/sandbox-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,22 +32,22 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@deepseek-ai/dsh-sandbox-windows-acl": "workspace:^", "@deepseek-ai/node-addon-landlock-run": "workspace:*", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/sandbox/sandbox-local/src/index.ts b/packages/sandbox/sandbox-local/src/index.ts index fc19a8dbea..8880da8103 100644 --- a/packages/sandbox/sandbox-local/src/index.ts +++ b/packages/sandbox/sandbox-local/src/index.ts @@ -7,20 +7,21 @@ * * The windows-acl rung additionally owns the write grants: the write SID is * the per-WORKSPACE identity derived from the canonical workspace path - * (`workspaceWriteSid`), and the private temp subdirectory is DERIVED per - * session (session id + workspace — nothing stored). The + * (`workspaceWriteSid`), while every live session receives a RANDOM private + * temp directory and its own derived capability (`tempWriteSid`). The * workspace-root ACE materializes once per workspace per server lifetime * and STANDS (the cross-session reuse cache — the exact-ACE skip makes * every later provision O(1) instead of re-propagating the tree per * session); the private-temp ACEs are revoked on dispose. The runner - * receives `--write-sid` (the derived identity; its presence marks the - * seam-managed contract) and stops managing DACLs itself. + * receives both SIDs (their presence marks the seam-managed contract) and + * stops managing DACLs itself. The rung reports partial enforcement because + * WRITE_RESTRICTED must retain Everyone in its + * restricting list and NTFS hard links alias one file object across paths. * @module @deepseek-ai/dsh-sandbox-local */ import { spawnSync } from 'node:child_process' -import { createHash } from 'node:crypto' -import { existsSync, mkdirSync, rmSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' @@ -30,13 +31,13 @@ import { launcherPath as landlockLauncherPath, probe as defaultProbeLandlock, } from '@deepseek-ai/node-addon-landlock-run' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { assertNever } from '@deepseek-ai/dsh-llm' import { SandboxProvider, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { ConfinedArgv, ConfinedSandboxMode, RunnerFailureRule, SandboxEnforcement, SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import type { SessionId } from '@deepseek-ai/dsh-session' -import { AclWriteGrant, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { AclWriteGrant, assertTempRootOutsideWorkspace, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' import { bwrapProfileArgs, landlockProfileArgs, seatbeltProfileArgs } from './profiles.ts' /** Plugin config. All optional — `static Config` supplies the defaults. */ @@ -110,25 +111,6 @@ function defaultProbeWindowsAcl(runnerInvocation: string[], timeoutMs: number): return probe.status === 0 } -/** - * The session's private temp subdirectory: `\dsh-<16 hex>`, derived - * from the session id and its workspace instead of stored. The same session - * and workspace always name the same directory — a resumed session - * re-grants it (the exact-ACE skip keeps that O(1)) — while a fork's - * different session id names a fresh one. The name is predictable to anyone - * who knows the session id (the confined command sees it as - * `DSH_SESSION_ID`), so the provider creates the directory EXCLUSIVELY and - * rejects reparse points: a pre-placed entry fails the first confined run - * loudly, and cannot redirect the grant onto a foreign object. - * @param sessionId - the policy's calling-session identity. - * @param workspaceRoot - the resolved policy root. - * @returns the session's private temp subdirectory path. - */ -export function sessionTempDir(sessionId: SessionId, workspaceRoot: string): string { - const digest = createHash('sha256').update(String(sessionId)).update('\0').update(workspaceRoot).digest('hex') - return join(tmpdir(), `dsh-${digest.slice(0, 16)}`) -} - /** Test hook: inject probe verdicts / a fake launcher / a platform without real runners. */ export interface SandboxInternals { /** Replaces `process.platform` for chain selection (exercise any platform's chain from any host). */ @@ -158,6 +140,13 @@ export interface SandboxInternals { /** The chain's verdict: which runner confines, and how completely it enforces. */ type SelectedRunner = { runner: 'bwrap' | 'landlock' | 'seatbelt' | 'windows-acl'; enforcement: SandboxEnforcement } +/** One live session/workspace pair's private temp directory and capability. */ +interface AclTempCapability { + dir: string + writeSid: string + grant: AclWriteGrant +} + /** * The runner chain per platform — selection is BY PLATFORM first, probes * second: a platform's chain is probed in preference order only when it has @@ -189,13 +178,12 @@ const STATIC_ENFORCEMENT: Record = bwrap: 'full', landlock: 'full', seatbelt: 'full', - // 'full' is the SUPPORTED-SURFACE promise: on NTFS both restricting lists - // close every ambient write (INTERACTIVE/LOCAL and Authenticated Users are - // absent from both — pinned by the runner's Public-probe and CIM-denial - // regressions). FAT-class (non-ACL) targets are declared unsupported - // (warn-only) in the backend README — outside the promise, not an - // exception to it. - 'windows-acl': 'full', + // WRITE_RESTRICTED needs Everyone in both restricting lists for process + // initialization. An external object that grants Everyone write access + // therefore remains writable, and NTFS hard links can alias a granted + // workspace file to a path outside it. The backend enforces the remaining + // ACL-addressable surface but must not advertise the absolute promise. + 'windows-acl': 'partial', } /** @@ -255,8 +243,9 @@ const RUNNER_FAILURE_RULES = { * Local process-sandbox provider. Registers as `ctx.sandbox`. Caches the * chain verdict and, on the windows-acl rung, the write grants * ({@link AclWriteGrant}: the standing workspace-root grant per workspace - * and the revocable private-temp grant per session, the latter revoked on - * provider dispose); the one-time probes spawn nothing else. + * and the revocable private-temp grant per live session/workspace pair, the + * latter revoked on provider dispose); the one-time probes spawn nothing + * else. */ export class LocalSandboxProvider extends SandboxProvider { // Inline schema call: the config catalog walks `static Config` statically. @@ -278,12 +267,11 @@ export class LocalSandboxProvider extends SandboxProvider { * Server-lifetime write grants (windows-acl rung): the STANDING * workspace-root grant per workspace (its ACE is the cross-session reuse * cache and outlives the provider — never revoked) and the REVOCABLE - * private-temp grant per session (revoked on provider dispose). + * private-temp grant per live session/workspace pair (revoked on provider + * dispose). */ private readonly workspaceGrants = new Map() - private readonly tempGrants = new Map() - /** Session id → the private temp directory this provider created (removed on dispose). */ - private readonly tempDirs = new Map() + private readonly tempCapabilities = new Map() constructor(ctx: Context, config: Config) { super(ctx) @@ -357,20 +345,19 @@ export class LocalSandboxProvider extends SandboxProvider { /** * The windows-acl runner argv for one policy. With a calling session (the - * policy's `sessionId`), the write grants are materialized once per server - * lifetime — the standing workspace-root grant per workspace and the - * revocable private-temp grant per session — and the runner receives - * `--write-sid` (the workspace-derived identity; its presence marks the - * seam-managed DACL contract) plus, under workspace-write, the session's - * PRIVATE temp subdirectory (derived from session id + workspace) — it - * grants nothing and revokes nothing. Agentless calls pass the ambient - * temp root and no `--write-sid`: the runner self-manages its DACLs. + * policy's `sessionId`) under workspace-write, the grants are materialized + * once per provider lifetime — the standing workspace-root grant per + * workspace and a revocable, RANDOM private-temp capability per live + * session/workspace pair. The runner receives `--write-sid` plus + * `--temp-write-sid` and grants nothing itself. Agentless workspace-write + * calls pass the ambient temp ROOT and no SID flags: the runner creates and + * removes a random private child directory for that one invocation. * @param policy - the resolved per-call policy. * @returns the runner invocation. */ private windowsAclRunnerArgv(policy: SandboxPolicy): string[] { const sessionId = policy.sessionId - if (sessionId === undefined) { + if (sessionId === undefined || policy.mode === 'read-only') { return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, @@ -378,45 +365,33 @@ export class LocalSandboxProvider extends SandboxProvider { '--mode', policy.mode, ] } - this.materializeAclGrant(sessionId, policy.workspaceRoot, policy.mode) + const temp = this.materializeAclGrant(sessionId, policy.workspaceRoot) return [ ...this.windowsAclRunnerInvocation(), '--workspace', policy.workspaceRoot, - // Workspace-write sessions confine their temp writes to the PRIVATE - // per-session subdirectory (bwrap --tmpfs /tmp semantics); read-only - // runs pass the ambient temp root — the runner validates it exists - // but grants nothing. The derived write SID is the per-workspace - // identity; the flag's presence marks the seam-managed DACL contract. - '--temp', policy.mode === 'workspace-write' ? sessionTempDir(sessionId, policy.workspaceRoot) : tmpdir(), + '--temp', temp.dir, '--mode', policy.mode, '--write-sid', workspaceWriteSid(policy.workspaceRoot), + '--temp-write-sid', temp.writeSid, ] } /** - * Materialize the session's ACEs once per server lifetime: lazily at its - * first confined execution, reused for every later call (the map hits are - * the whole call). The write SID is the per-workspace identity derived - * from the workspace. Workspace-write grants the workspace root STANDING - * (the ACE outlives every session — the reuse cache) and the session's - * private temp subdirectory REVOCABLY — the directory is derived from - * session id + workspace, created here EXCLUSIVELY (a pre-existing entry - * or a reparse point fails the first confined run loudly, so the grant - * never lands on a foreign object); read-only materializes NOTHING — its - * token alone restricts every write, and the standing grant from an - * earlier workspace-write period is KEPT through a downgrade (never - * revoked): the read-only restricted token carries no write SID (the - * read-only list), so the ACE is inert there, while the map hit keeps the - * re-upgrade free of re-propagation. Fail-closed: a half-materialized - * temp grant is revoked before the error propagates. + * Materialize one workspace-write policy's ACEs once per provider + * lifetime. The workspace SID and standing root grant are shared by the + * workspace. The temp directory is random and carries a distinct SID, so + * another session on the same workspace cannot use the shared workspace + * SID to enter it. A fresh provider always chooses a new path; crash + * residue therefore cannot collide with or authorize a resumed session. + * Fail-closed: a half-materialized temp grant is revoked and its directory + * removed before the error propagates. * @param sessionId - the policy's calling-session identity. * @param workspaceRoot - the resolved policy root. - * @param mode - the policy mode (grants exist only under workspace-write). + * @returns the pair's private temp directory and write capability. */ - private materializeAclGrant(sessionId: SessionId, workspaceRoot: string, mode: ConfinedSandboxMode): void { - if (mode === 'read-only') return + private materializeAclGrant(sessionId: SessionId, workspaceRoot: string): AclTempCapability { + assertTempRootOutsideWorkspace(workspaceRoot, tmpdir()) const writeSid = workspaceWriteSid(workspaceRoot) - const tempDir = sessionTempDir(sessionId, workspaceRoot) if (!this.workspaceGrants.has(workspaceRoot)) { const grant = AclWriteGrant.create(writeSid) try { @@ -434,32 +409,37 @@ export class LocalSandboxProvider extends SandboxProvider { } this.workspaceGrants.set(workspaceRoot, grant) } - if (this.tempGrants.has(sessionId)) return - const grant = AclWriteGrant.create(writeSid) - // The directory is removed again in the catch only when THIS confine - // created it — a pre-existing entry (EEXIST) is a foreign object and is - // never deleted. - let created = false + const key = JSON.stringify([String(sessionId), workspaceRoot]) + const existing = this.tempCapabilities.get(key) + if (existing !== undefined) return existing + const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) + const tempSid = tempWriteSid(tempDir) + let grant: AclWriteGrant | undefined try { - // Exclusive creation (no `recursive`): a pre-existing entry OR a - // reparse point both fail EEXIST — the grant never lands on a foreign - // object. - mkdirSync(tempDir) - created = true + grant = AclWriteGrant.create(tempSid) grant.add(tempDir) } catch (error) { - if (created) rmSync(tempDir, { recursive: true, force: true }) - // Revoke whatever stands and free the SID — never leave a half-grant - // behind a failed confine (the runner never runs). + const cleanupFailures: unknown[] = [] + if (grant !== undefined) { + try { + grant.dispose() + } catch (cleanupError) { + cleanupFailures.push(cleanupError) + } + } try { - grant.dispose() + this.removeTempDir(tempDir) } catch (cleanupError) { - throw new AggregateError([error, cleanupError], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed') + cleanupFailures.push(cleanupError) + } + if (cleanupFailures.length > 0) { + throw new AggregateError([error, ...cleanupFailures], 'sandbox-local windows-acl temp grant materialization failed and its cleanup also failed') } throw error } - this.tempGrants.set(sessionId, grant) - this.tempDirs.set(sessionId, tempDir) + const capability = { dir: tempDir, writeSid: tempSid, grant } + this.tempCapabilities.set(key, capability) + return capability } /** @@ -468,36 +448,40 @@ export class LocalSandboxProvider extends SandboxProvider { * removed, and every SID allocation is freed; the standing workspace ACEs * stay (the reuse cache). Cleanup failures are reported, not thrown: * cordis teardown must not be aborted by grant cleanup. A crash skips all - * of it — the next resume then fails loudly at the exclusive creation and - * OS temp hygiene (or manual removal) recovers. + * of it, but a new provider never reuses the residue's random path or SID; + * OS temp hygiene (or manual removal) eventually reclaims it. */ private revokeAclGrants(): void { - if (this.workspaceGrants.size === 0 && this.tempGrants.size === 0) return + if (this.workspaceGrants.size === 0 && this.tempCapabilities.size === 0) return const failures: unknown[] = [] - for (const grant of [...this.workspaceGrants.values(), ...this.tempGrants.values()]) { + for (const grant of [...this.workspaceGrants.values(), ...[...this.tempCapabilities.values()].map(capability => capability.grant)]) { try { grant.dispose() } catch (error) { failures.push(error) } } - const rmTempDir = this.internals.rmTempDir ?? ((dir: string) => { rmSync(dir, { recursive: true, force: true }) }) - for (const dir of this.tempDirs.values()) { + for (const { dir } of this.tempCapabilities.values()) { try { - rmTempDir(dir) + this.removeTempDir(dir) } catch (error) { failures.push(error) } } this.workspaceGrants.clear() - this.tempGrants.clear() - this.tempDirs.clear() + this.tempCapabilities.clear() if (failures.length > 0) { this.ctx.logger.warn(`sandbox-local: windows-acl grant cleanup completed with ${failures.length} failure(s)`) for (const error of failures) this.ctx.logger.warn(error) } } + /** Remove one provider-owned private temp directory (injectable for cleanup tests). */ + private removeTempDir(dir: string): void { + const remove = this.internals.rmTempDir ?? ((path: string) => { rmSync(path, { recursive: true, force: true }) }) + remove(dir) + } + /** * Resolve which runner confines commands, once, for the provider's * lifetime: this platform's chain ({@link PLATFORM_CHAINS}), its sole @@ -529,8 +513,9 @@ export class LocalSandboxProvider extends SandboxProvider { private probeRunner(runner: SelectedRunner['runner']): SandboxEnforcement | 'unusable' { // bwrap's mount profile and Seatbelt's deny-file-write* profile govern // every promised file effect by construction, so their passing probes - // are always full enforcement; only the Landlock launcher's probe report - // distinguishes full from per-ABI-partial. + // are always full enforcement; the Landlock launcher's probe report + // distinguishes full from per-ABI-partial, while windows-acl is always + // partial for its documented Everyone and hard-link boundaries. switch (runner) { case 'bwrap': { const probe = this.internals.probeBwrap ?? (() => defaultProbeBwrap(this.probeTimeoutMs)) @@ -547,7 +532,7 @@ export class LocalSandboxProvider extends SandboxProvider { case 'windows-acl': { const probe = this.internals.probeWindowsAcl ?? (() => defaultProbeWindowsAcl(this.windowsAclRunnerInvocation(), this.probeTimeoutMs)) - return probe() ? 'full' : 'unusable' + return probe() ? 'partial' : 'unusable' } default: return assertNever(runner) } diff --git a/packages/sandbox/sandbox-local/src/invariant.ts b/packages/sandbox/sandbox-local/src/invariant.ts index e990d46acc..e4f0891631 100644 --- a/packages/sandbox/sandbox-local/src/invariant.ts +++ b/packages/sandbox/sandbox-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-local' diff --git a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts index ca2410c317..67b77f096f 100644 --- a/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts +++ b/packages/sandbox/sandbox-local/tests/acl-grants.spec.ts @@ -1,27 +1,26 @@ /** - * windows-acl write grants: the SERVER-LIFETIME ACE materialization - * (standing workspace grant per workspace, revocable private-temp grant per - * session) plus the derived private-temp identity, through the REAL - * LocalSandboxProvider.confine(). Win32 surface mocked at the package - * boundary (the workspace-derived SID mocked to a constant); the real-FFI - * grant behavior lives in sandbox-windows-acl's win32 tests. + * windows-acl grant ownership through the real LocalSandboxProvider: one + * standing capability per workspace plus one random, distinct, revocable + * temp capability per live session/workspace pair. The Win32 grant surface + * is mocked; native access checks live in sandbox-windows-acl's runner suite. */ -import { existsSync, mkdirSync, mkdtempSync, rmSync, symlinkSync } from 'node:fs' +import { existsSync, mkdtempSync, realpathSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { SessionId } from '@deepseek-ai/dsh-session' -import { LocalSandboxProvider, sessionTempDir } from '@deepseek-ai/dsh-sandbox-local' +import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' /** Cross-file state shared with the vi.mock factory (hoisting contract). */ const mockState = vi.hoisted(() => ({ grants: [] as Array<{ writeSid: string; added: Array<{ path: string; standing: boolean }>; disposed: boolean }>, addFailure: undefined as Error | undefined, - /** Restricts {@link addFailure} to this path (undefined = every add throws). */ - addFailurePath: undefined as string | undefined, + /** Restrict an add failure to standing (workspace) or revocable (temp). */ + addFailureStanding: undefined as boolean | undefined, + createTempFailure: undefined as Error | undefined, disposeFailure: undefined as Error | undefined, })) @@ -35,24 +34,36 @@ vi.mock('@deepseek-ai/dsh-sandbox-windows-acl', () => { mockState.grants.push(this) } static create(writeSid: string): MockAclWriteGrant { + if (writeSid.startsWith('TEMP:') && mockState.createTempFailure !== undefined) throw mockState.createTempFailure return new MockAclWriteGrant(writeSid) } add(path: string, standing = false): void { - if (mockState.addFailure !== undefined && (mockState.addFailurePath === undefined || mockState.addFailurePath === path)) { + this.added.push({ path, standing }) + if (mockState.addFailure !== undefined + && (mockState.addFailureStanding === undefined || mockState.addFailureStanding === standing)) { throw mockState.addFailure } - this.added.push({ path, standing }) } dispose(): void { if (mockState.disposeFailure !== undefined) throw mockState.disposeFailure this.disposed = true } } - return { AclWriteGrant: MockAclWriteGrant, workspaceWriteSid: () => 'S-1-4-42-42' } + return { + AclWriteGrant: MockAclWriteGrant, + assertTempRootOutsideWorkspace: (workspaceRoot: string, tempRoot: string) => { + const workspace = realpathSync.native(workspaceRoot) + const temp = realpathSync.native(tempRoot) + if (temp === workspace || temp.startsWith(`${workspace}${process.platform === 'win32' ? '\\' : '/'}`)) { + throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`) + } + }, + workspaceWriteSid: () => 'S-1-4-42-42', + tempWriteSid: (path: string) => `TEMP:${path}`, + } }) -/** The workspace-derived write SID the mock pins for every workspace. */ -const DERIVED_SID = 'S-1-4-42-42' +const WORKSPACE_SID = 'S-1-4-42-42' async function setup() { const ctx = new Context() @@ -62,279 +73,232 @@ async function setup() { return { ctx, sandbox, fiber } } -/** A workspace root the policy carries. */ function workspaceRoot(): string { return mkdtempSync(join(tmpdir(), 'dsh-acl-grants-ws-')) } +function flag(argv: readonly string[], name: string): string | undefined { + const index = argv.indexOf(name) + return index < 0 ? undefined : argv[index + 1] +} + describe('windows-acl write grants (LocalSandboxProvider)', () => { const scratch: string[] = [] beforeEach(() => { mockState.grants = [] mockState.addFailure = undefined - mockState.addFailurePath = undefined + mockState.addFailureStanding = undefined + mockState.createTempFailure = undefined mockState.disposeFailure = undefined }) const cleanup = () => { + for (const grant of mockState.grants) { + for (const added of grant.added) { + if (!added.standing) rmSync(added.path, { recursive: true, force: true }) + } + } for (const dir of scratch.splice(0)) rmSync(dir, { recursive: true, force: true }) } - it('workspace-write: first confine materializes ONCE (standing workspace + revocable private temp), the derived temp dir rides the argv', async () => { + it('workspace-write materializes one standing workspace grant and one private temp capability, then reuses both', async () => { try { const { sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const tempDir = sessionTempDir(SessionId('sess-1'), ws) - scratch.push(tempDir) const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-1') } const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + const tempDir = flag(confined.argv, '--temp') + const tempSid = flag(confined.argv, '--temp-write-sid') + expect(tempDir).toBeDefined() + expect(basename(tempDir ?? '')).toMatch(/^dsh-[A-Za-z0-9_-]{6}$/u) + expect(tempSid).toBe(`TEMP:${tempDir}`) + expect(tempSid).not.toBe(WORKSPACE_SID) expect(confined.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, '--temp', tempDir, '--mode', 'workspace-write', - '--write-sid', DERIVED_SID, + '--write-sid', WORKSPACE_SID, + '--temp-write-sid', tempSid, '--', 'pwsh', '/Command', 'x', ]) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: ws, standing: true }], // standing: the reuse cache, never revoked - disposed: false, - }) - expect(mockState.grants[1]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: tempDir, standing: false }], - disposed: false, - }) - expect(existsSync(tempDir)).toBe(true) // created exclusively + expect(mockState.grants).toEqual([ + expect.objectContaining({ writeSid: WORKSPACE_SID, added: [{ path: ws, standing: true }], disposed: false }), + expect.objectContaining({ writeSid: tempSid, added: [{ path: tempDir, standing: false }], disposed: false }), + ]) + expect(existsSync(tempDir ?? '')).toBe(true) - // Reuse: the second confine is the map hits. - sandbox.confine(['pwsh', '/Command', 'x'], policy) + expect(sandbox.confine(['pwsh', '/Command', 'x'], policy).argv).toEqual(confined.argv) expect(mockState.grants).toHaveLength(2) await fiber.dispose() - // dispose() runs on BOTH grants: the standing workspace ACE is left in - // place (the mock marks it disposed only as instance teardown). - expect(mockState.grants[0]!.disposed).toBe(true) - expect(mockState.grants[1]!.disposed).toBe(true) + expect(mockState.grants.every(grant => grant.disposed)).toBe(true) + expect(existsSync(tempDir ?? '')).toBe(false) } finally { cleanup() } }) - it('mode switch: read-only materializes nothing, the upgrade materializes ONCE with the derived SID, the downgrade keeps the standing grant', async () => { + it('read-only materializes no capability; upgrade creates them and downgrade leaves them reusable', async () => { try { - const { sandbox } = await setup() + const { sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - const tempDir = sessionTempDir(SessionId('sess-switch'), ws) - scratch.push(tempDir) - const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('sess-switch') } - const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-switch') } + const readOnly: SandboxPolicy = { mode: 'read-only', workspaceRoot: ws, sessionId: SessionId('switch') } + const workspaceWrite: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('switch') } - // read-only first: nothing materialized, ambient temp. - const confinedRo = sandbox.confine(['true'], readOnly) - expect(confinedRo.argv).toEqual([ + expect(sandbox.confine(['true'], readOnly).argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', ws, - '--temp', tmpdir(), // NOT the private subdir: read-only grants nothing + '--temp', tmpdir(), '--mode', 'read-only', - '--write-sid', DERIVED_SID, '--', 'true', ]) expect(mockState.grants).toHaveLength(0) - expect(existsSync(tempDir)).toBe(false) - // Upgrade: first workspace-write materializes with the derived SID. const upgraded = sandbox.confine(['true'], workspaceWrite) - expect(upgraded.argv).toEqual([ - 'node', 'windows-acl-runner.js', - '--workspace', ws, - '--temp', tempDir, - '--mode', 'workspace-write', - '--write-sid', DERIVED_SID, - '--', - 'true', - ]) + expect(flag(upgraded.argv, '--temp-write-sid')).not.toBe(WORKSPACE_SID) expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]).toMatchObject({ writeSid: DERIVED_SID, added: [{ path: ws, standing: true }], disposed: false }) - expect(mockState.grants[1]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: tempDir, standing: false }], - disposed: false, - }) - expect(existsSync(tempDir)).toBe(true) - - // Reuse: map hits. - sandbox.confine(['true'], workspaceWrite) - expect(mockState.grants).toHaveLength(2) - - // Downgrade: standing grant KEPT (inert under read-only, free re-upgrade). sandbox.confine(['true'], readOnly) expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]!.disposed).toBe(false) + expect(mockState.grants.every(grant => !grant.disposed)).toBe(true) + expect(sandbox.confine(['true'], workspaceWrite).argv).toEqual(upgraded.argv) + + await fiber.dispose() } finally { cleanup() } }) - it('resume: a fresh provider derives the SAME temp dir for the same session and workspace and re-grants it', async () => { + it('a fresh provider gives a resumed session a new temp path and SID, so crash residue cannot collide', async () => { try { const ws = workspaceRoot() scratch.push(ws) - const first = await setup() const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('resumed') } + const first = await setup() const firstConfined = first.sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(2) + const firstTemp = flag(firstConfined.argv, '--temp') ?? '' - // Clean restart: dispose revokes the temp ACE and removes the private - // temp directory, so the fresh provider's exclusive creation succeeds. - await first.fiber.dispose() - mockState.grants = [] + // The first provider remains live: model an unclean prior process whose + // temp directory and ACE survived. A new provider must still proceed. const second = await setup() const secondConfined = second.sandbox.confine(['true'], policy) - expect(secondConfined.argv).toEqual(firstConfined.argv) - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[1]).toMatchObject({ - writeSid: DERIVED_SID, - added: [{ path: sessionTempDir(SessionId('resumed'), ws), standing: false }], - }) + const secondTemp = flag(secondConfined.argv, '--temp') ?? '' + expect(secondTemp).not.toBe(firstTemp) + expect(flag(secondConfined.argv, '--temp-write-sid')).not.toBe(flag(firstConfined.argv, '--temp-write-sid')) + expect(existsSync(firstTemp)).toBe(true) + expect(existsSync(secondTemp)).toBe(true) + await second.fiber.dispose() + await first.fiber.dispose() } finally { cleanup() } }) - it('fork: a different session id derives a DIFFERENT private temp identity over the same workspace', async () => { + it('forks and workspace changes receive distinct temp capabilities while each workspace grant is reused', async () => { try { - const { sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - const parentPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('parent') } - const childPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('child') } + const { sandbox, fiber } = await setup() + const wsA = workspaceRoot() + const wsB = workspaceRoot() + scratch.push(wsA, wsB) + const parent = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('parent') }) + const child = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsA, sessionId: SessionId('child') }) + const moved = sandbox.confine(['true'], { mode: 'workspace-write', workspaceRoot: wsB, sessionId: SessionId('parent') }) - sandbox.confine(['true'], parentPolicy) - const parentTemp = sessionTempDir(SessionId('parent'), ws) - scratch.push(parentTemp) - sandbox.confine(['true'], childPolicy) - const childTemp = sessionTempDir(SessionId('child'), ws) - scratch.push(childTemp) + expect(flag(child.argv, '--temp')).not.toBe(flag(parent.argv, '--temp')) + expect(flag(child.argv, '--temp-write-sid')).not.toBe(flag(parent.argv, '--temp-write-sid')) + expect(flag(moved.argv, '--temp')).not.toBe(flag(parent.argv, '--temp')) + expect(mockState.grants).toHaveLength(5) // workspace A + two temps + workspace B + one temp - // Fresh temp identity, NOT the parent's (the workspace SID is shared by - // derivation — the workspace is the same, so the standing grant is the - // map hit and only the child's temp grant joins). - expect(childTemp).not.toBe(parentTemp) - expect(mockState.grants).toHaveLength(3) - expect(mockState.grants[2]).toMatchObject({ added: [{ path: childTemp, standing: false }] }) + await fiber.dispose() } finally { cleanup() } }) - it('creates the private temp dir EXCLUSIVELY: a pre-existing entry or a reparse point fails EEXIST, never receiving the temp grant', async () => { + it('workspace grant failure disposes its SID, aggregates cleanup failure, and never creates a temp directory', async () => { try { const { sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - - // Pre-existing entry: exclusive mkdir throws EEXIST instead of adopting it. - const preexisting = sessionTempDir(SessionId('preexisting'), ws) - mkdirSync(preexisting) - scratch.push(preexisting) - const prePolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('preexisting') } - expect(() => sandbox.confine(['true'], prePolicy)).toThrow(/EEXIST/) - // The standing workspace grant is the intended end state and stays; the - // failed temp grant self-disposes. - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]!.disposed).toBe(false) - expect(mockState.grants[1]!.disposed).toBe(true) // self-revoked - - // Reparse point: same EEXIST (exclusive mkdir never follows links). - const target = mkdtempSync(join(tmpdir(), 'dsh-acl-junction-target-')) - scratch.push(target) - const linkPath = sessionTempDir(SessionId('reparse'), ws) - symlinkSync(target, linkPath) - scratch.push(linkPath) - const linkPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('reparse') } - expect(() => sandbox.confine(['true'], linkPolicy)).toThrow(/EEXIST/) - // Same workspace as the preexisting case: the standing workspace grant - // is the map hit (not recreated) — only the failed temp grant joins. - expect(mockState.grants).toHaveLength(3) - expect(mockState.grants[2]!.disposed).toBe(true) - - // Temp-side cleanup failure: the standing workspace grant stays (map - // hit), the exclusive mkdir fails, AND the temp grant's dispose also - // fails — the temp cleanup AggregateError propagates. - mockState.grants = [] - mockState.disposeFailure = new Error('temp cleanup exploded') - const dupTemp = sessionTempDir(SessionId('temp-cleanup-fail'), ws) - mkdirSync(dupTemp) - scratch.push(dupTemp) - const dupPolicy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('temp-cleanup-fail') } - expect(() => sandbox.confine(['true'], dupPolicy)).toThrow(/temp grant materialization failed and its cleanup also failed/) - expect(mockState.grants).toHaveLength(1) // only the failed temp grant (the workspace grant was the map hit) - } finally { - cleanup() - } - }) - - it('a grant failure mid-materialization disposes the failed grant and rethrows (AggregateError when the cleanup also fails)', async () => { - try { - const { sandbox } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - scratch.push(sessionTempDir(SessionId('sess-add-fail'), ws)) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-add-fail') } - - // add() throws on the FIRST (workspace) grant: cleanup dispose() runs, original error propagates. - mockState.addFailure = new Error('grant exploded') - expect(() => sandbox.confine(['true'], policy)).toThrow('grant exploded') + mockState.addFailureStanding = true + mockState.addFailure = new Error('workspace grant exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-fail'), + })).toThrow('workspace grant exploded') expect(mockState.grants).toHaveLength(1) expect(mockState.grants[0]!.disposed).toBe(true) - // add() AND dispose() both throw: AggregateError. - mockState.grants = [] - mockState.addFailure = new Error('grant exploded again') - mockState.disposeFailure = new Error('cleanup exploded') - expect(() => sandbox.confine(['true'], policy)).toThrow(AggregateError) + mockState.disposeFailure = new Error('workspace cleanup exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('workspace-cleanup-fail'), + })).toThrow(/workspace grant failed and its cleanup also failed/u) + expect(mockState.grants).toHaveLength(2) } finally { cleanup() } }) - it('a temp add failure after the exclusive mkdir removed the half-created directory again', async () => { + it('rejects a workspace containing the ambient temp root before any ACL mutation', async () => { + const { sandbox } = await setup() + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: realpathSync.native(tmpdir()), sessionId: SessionId('overlap'), + })).toThrow(/temp root must be outside the workspace/u) + expect(mockState.grants).toHaveLength(0) + }) + + it('temp grant creation/add failures remove the random directory; cleanup failures aggregate', async () => { try { const { sandbox } = await setup() const ws = workspaceRoot() scratch.push(ws) - const tempDir = sessionTempDir(SessionId('sess-temp-add-fail'), ws) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-temp-add-fail') } - // The workspace grant succeeds; only the TEMP grant's add throws (the - // path-targeted failure keeps the workspace branch intact). - mockState.addFailurePath = tempDir + mockState.createTempFailure = new Error('temp SID creation exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('create-fail'), + })).toThrow('temp SID creation exploded') + expect(mockState.grants).toHaveLength(1) // workspace only; random temp was removed + + mockState.createTempFailure = undefined + mockState.addFailureStanding = false mockState.addFailure = new Error('temp add exploded') - expect(() => sandbox.confine(['true'], policy)).toThrow('temp add exploded') - expect(existsSync(tempDir)).toBe(false) // the half-created directory is removed again - expect(mockState.grants).toHaveLength(2) - expect(mockState.grants[0]!.disposed).toBe(false) // the standing workspace grant stays - expect(mockState.grants[1]!.disposed).toBe(true) // the failed temp grant self-disposes + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('add-fail'), + })).toThrow('temp add exploded') + const failedTempGrant = mockState.grants.at(-1) + expect(failedTempGrant?.disposed).toBe(true) + expect(failedTempGrant?.added).toHaveLength(1) + expect(existsSync(failedTempGrant?.added[0]?.path ?? '')).toBe(false) + + mockState.addFailureStanding = false + mockState.addFailure = new Error('temp add exploded') + sandbox.internals.rmTempDir = () => { throw new Error('temp rm exploded') } + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('rm-fail'), + })).toThrow(/temp grant materialization failed and its cleanup also failed/u) + delete sandbox.internals.rmTempDir + + mockState.addFailureStanding = false + mockState.addFailure = new Error('temp add exploded') + mockState.disposeFailure = new Error('temp cleanup exploded') + expect(() => sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('aggregate-fail'), + })).toThrow(/temp grant materialization failed and its cleanup also failed/u) } finally { cleanup() } }) - it('agentless calls stay self-managed: no --write-sid, the ambient temp root, no grants', async () => { + it('agentless calls pass a temp root and no capabilities; the runner owns the private child lifecycle', async () => { try { const { sandbox, fiber } = await setup() - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: '/ws' } - const confined = sandbox.confine(['pwsh', '/Command', 'x'], policy) + const confined = sandbox.confine(['pwsh', '/Command', 'x'], { mode: 'workspace-write', workspaceRoot: '/ws' }) expect(confined.argv).toEqual([ 'node', 'windows-acl-runner.js', '--workspace', '/ws', @@ -350,55 +314,26 @@ describe('windows-acl write grants (LocalSandboxProvider)', () => { } }) - it('a failing dispose at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => { + it('provider teardown reports grant and directory cleanup failures without aborting teardown', async () => { try { const { ctx, sandbox, fiber } = await setup() const ws = workspaceRoot() scratch.push(ws) - scratch.push(sessionTempDir(SessionId('sess-dispose'), ws)) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-dispose') } - sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(2) - + const confined = sandbox.confine(['true'], { + mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('dispose'), + }) + const tempDir = flag(confined.argv, '--temp') ?? '' mockState.disposeFailure = new Error('revoke exploded') - const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) - await fiber.dispose() - // BOTH grants (standing workspace + revocable temp) fail their dispose. - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 2 failure(s)')) - expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) - } finally { - cleanup() - } - }) - - it('a failing private-temp removal at provider teardown is reported via ctx.logger.warn and never thrown into teardown', async () => { - try { - const { ctx, sandbox, fiber } = await setup() - const ws = workspaceRoot() - scratch.push(ws) - scratch.push(sessionTempDir(SessionId('sess-rm-fail'), ws)) - const policy: SandboxPolicy = { mode: 'workspace-write', workspaceRoot: ws, sessionId: SessionId('sess-rm-fail') } - sandbox.confine(['true'], policy) - expect(mockState.grants).toHaveLength(2) - sandbox.internals.rmTempDir = () => { throw new Error('rm exploded') } const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => undefined) + await fiber.dispose() - // Both grants dispose cleanly; only the directory removal fails. - expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 1 failure(s)')) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('cleanup completed with 3 failure(s)')) + expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'revoke exploded' })) expect(warn).toHaveBeenCalledWith(expect.objectContaining({ message: 'rm exploded' })) + expect(existsSync(tempDir)).toBe(true) // injected removal failed; test cleanup reclaims it } finally { cleanup() } }) - - it('sessionTempDir derives the same well-shaped name for the same session and workspace, distinct otherwise', () => { - const base = sessionTempDir(SessionId('sess-a'), '/ws/a') - expect(basename(base)).toMatch(/^dsh-[0-9a-f]{16}$/) - expect(sessionTempDir(SessionId('sess-a'), '/ws/a')).toBe(base) - expect(sessionTempDir(SessionId('sess-b'), '/ws/a')).not.toBe(base) // different session - expect(sessionTempDir(SessionId('sess-a'), '/ws/b')).not.toBe(base) // different workspace - // The separator prevents id/workspace collisions from merging inputs. - expect(sessionTempDir(SessionId('ab'), '/ws/c')).not.toBe(sessionTempDir(SessionId('a'), '/ws/bc')) - }) }) diff --git a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts index 9a093e4632..3da6787049 100644 --- a/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/bwrap.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { bwrapProfileArgs } from '../src/profiles.ts' diff --git a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts index ff4947a4ca..4bee858b78 100644 --- a/packages/sandbox/sandbox-local/tests/landlock.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/landlock.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' diff --git a/packages/sandbox/sandbox-local/tests/local.spec.ts b/packages/sandbox/sandbox-local/tests/local.spec.ts index 2edde27ca7..1586df3947 100644 --- a/packages/sandbox/sandbox-local/tests/local.spec.ts +++ b/packages/sandbox/sandbox-local/tests/local.spec.ts @@ -11,7 +11,7 @@ import { mkdtempSync, realpathSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { LAUNCHER_FAILURE_EXIT } from '@deepseek-ai/node-addon-landlock-run' import { SANDBOX_UNAVAILABLE, SandboxUnavailableError } from '@deepseek-ai/dsh-sandbox' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' @@ -381,7 +381,7 @@ describe('the windows-acl probe (runner invocation contract)', () => { const confined = sandbox.confine(['true'], RO) expect(probeWindowsAcl).toHaveBeenCalledTimes(1) expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) - expect(confined.enforcement).toBe('full') + expect(confined.enforcement).toBe('partial') expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) }) diff --git a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts index 4b95f4da58..6b7e547099 100644 --- a/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/packed-install.e2e.ts @@ -7,11 +7,11 @@ import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' /** - * Keyless publish-path rehearsal. It packs the provider, its workspace peers, and the current - * repository's Landlock entry/platform packages, then installs those exact tarballs in an external - * plain-Node consumer. The host launcher comes from the exact local tarballs, so no registry copy, - * tsx, path mapping, or workspace resolution can hide missing files, dependency errors, or lost - * executable modes. npm may still query registry metadata for an incompatible optional platform + * Keyless publish-path rehearsal. It packs the provider, its workspace peers, the vendored framework + * peer, and the current repository's Landlock entry/platform packages, then installs those exact + * tarballs in an external plain-Node consumer. The host launcher comes from the exact local tarballs, + * so no registry copy, tsx, path mapping, or workspace resolution can hide missing files, dependency + * errors, or lost executable modes. npm may still query registry metadata for an incompatible optional platform * package that cannot supply the host launcher. * * The installed launcher must match the host architecture, remain executable, and either confine a @@ -38,6 +38,13 @@ const WORKSPACE_CLOSURE = [ 'packages/util/brand', 'packages/util/timeout', 'packages/support/invariants', + // The framework and the vendored packages the closure declares outright: + // rescoped into @deepseek-ai, so the consumer installs this repository's + // copies. Schemastery is a hard dependency of three members above, not a + // peer, so npm resolves it while installing them. + 'vendor/cordis', + 'vendor/cosmokit', + 'vendor/schemastery', ] /** ELF `e_machine` (offset 18, LE) for this host: x86-64 = 62, AArch64 = 183. */ @@ -96,10 +103,10 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- } tarballs.push(...nativeTarballs) - // Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional + // Peer ranges resolve to the tarballs, the framework peer included. Do not omit optional // dependencies because the launcher selects its OS/CPU package through one. writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' })) - const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.7'], { + const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs], { cwd: consumerDir, encoding: 'utf8', timeout: 300_000, @@ -113,7 +120,7 @@ describe.skipIf(!packable)('sandbox-local: packed-tarball distribution (publish- writeFileSync(join(consumerDir, 'consumer.mjs'), ` import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' - import { Context } from 'cordis' + import { Context } from '@deepseek-ai/cordis' import { launcherPath } from '@deepseek-ai/node-addon-landlock-run' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' const ctx = new Context() diff --git a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts index 6d645b1a3b..4ff399dc67 100644 --- a/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts +++ b/packages/sandbox/sandbox-local/tests/seatbelt.e2e.ts @@ -4,7 +4,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { homedir, tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' import { seatbeltProfileArgs } from '../src/profiles.ts' diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 6124a75ff6..e5736878e3 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-sandbox-policy", "description": "Per-call sandbox policy resolver and current model context: deployment fallbacks plus each session's mode and workspace root, shared by every enforcing capability family", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/sandbox/sandbox-policy" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-sandbox": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -41,6 +48,6 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/sandbox/sandbox-policy/src/index.ts b/packages/sandbox/sandbox-policy/src/index.ts index 3a3aac1d70..eee5a43669 100644 --- a/packages/sandbox/sandbox-policy/src/index.ts +++ b/packages/sandbox/sandbox-policy/src/index.ts @@ -19,8 +19,8 @@ */ import { resolve as resolvePath } from 'node:path' -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' import { canonicalPath, type SandboxExecutionPolicy, type SandboxMode } from '@deepseek-ai/dsh-sandbox' import type { Session } from '@deepseek-ai/dsh-session' @@ -51,7 +51,7 @@ function renderPolicyContext(policy: SandboxExecutionPolicy): string { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sandboxPolicy: SandboxPolicyService } diff --git a/packages/sandbox/sandbox-policy/src/invariant.ts b/packages/sandbox/sandbox-policy/src/invariant.ts index 20fd176af6..32e2c998b7 100644 --- a/packages/sandbox/sandbox-policy/src/invariant.ts +++ b/packages/sandbox/sandbox-policy/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned session-event invariants for sandbox policy. @module @deepseek-ai/dsh-sandbox-policy/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { SANDBOX_MODES } from './session-mode.ts' diff --git a/packages/sandbox/sandbox-policy/tests/invariant.spec.ts b/packages/sandbox/sandbox-policy/tests/invariant.spec.ts index d3255b305e..5ed924273a 100644 --- a/packages/sandbox/sandbox-policy/tests/invariant.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' import * as SandboxPolicyInvariant from '@deepseek-ai/dsh-sandbox-policy/invariant' diff --git a/packages/sandbox/sandbox-policy/tests/policy.spec.ts b/packages/sandbox/sandbox-policy/tests/policy.spec.ts index 11552f2a3a..0a239114aa 100644 --- a/packages/sandbox/sandbox-policy/tests/policy.spec.ts +++ b/packages/sandbox/sandbox-policy/tests/policy.spec.ts @@ -8,7 +8,7 @@ import { mkdirSync, mkdtempSync, realpathSync, rmSync, symlinkSync } from 'node: import { tmpdir } from 'node:os' import { join, resolve, sep } from 'node:path' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { Session, SessionId } from '@deepseek-ai/dsh-session' import SandboxPolicyService, { SANDBOX_MODES, effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' diff --git a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml b/packages/sandbox/sandbox-windows-acl/README.i18n.yaml index c53e5cf6fc..51d933fbb1 100644 --- a/packages/sandbox/sandbox-windows-acl/README.i18n.yaml +++ b/packages/sandbox/sandbox-windows-acl/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/sandbox/sandbox-windows-acl/README.md -README.md: b13160f7490878143c719ca617936b74ffd298af -README.zh.md: 9895449f6f416ad971bbbfff700c9fd62ad99c44 +README.md: 280dc2b38844feff87eb792223b87ead251f3e16 +README.zh.md: 06121c3142bd788d0e1fe8cfa38fa8a668bb270a diff --git a/packages/sandbox/sandbox-windows-acl/README.md b/packages/sandbox/sandbox-windows-acl/README.md index b13160f749..280dc2b388 100644 --- a/packages/sandbox/sandbox-windows-acl/README.md +++ b/packages/sandbox/sandbox-windows-acl/README.md @@ -2,52 +2,63 @@ English | [中文](README.zh.md) -Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. +Windows write-restriction sandbox backend for the [harness sandbox seam](../sandbox/): a Node.js/[koffi](https://koffi.dev/) port of the mechanism in [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc) (`10e4dfb`, the fixed revision), mounted as the `enforcement: 'partial'` win32 rung of the [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) chain (`workspace-write` / `read-only` modes); the same package carries the Linux/macOS backends. -Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs include a write SID (`S-1-4-x-y`) whose Write ACEs exist only on the workspace and the session's private temp directory. The write SID is the per-WORKSPACE identity, derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine — every later session, call, or restart hits the exact-ACE skip — instead of once per session (see [The confinement runner](#the-confinement-runner)). Windows then grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it — the write SID is the write allowlist, and it grants nothing anywhere else on the system; the token's write check also inherits the ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone — the Modes section below is the complete boundary). +Mechanism in one line: the caller's token is duplicated into a `WRITE_RESTRICTED` token whose restricting SIDs carry separate workspace and private-temp capabilities. The workspace SID is derived deterministically from the canonical workspace path (`workspaceWriteSid`), so the workspace-root ACE materializes once per workspace per machine and every later session, call, or restart hits the exact-ACE skip. Each live session/workspace pair instead receives a random temp directory and a SID derived from that path (`tempWriteSid`), so sessions share the intended workspace authority without inheriting one another's temp authority. Windows grants a write only where BOTH the caller's normal access AND the restricting-SID intersection allow it. These SIDs are the primary allowlists and grant nothing elsewhere, but the check also inherits ambient write ACEs of the OTHER restricting SIDs (the keep-alive group logon SID + Everyone), and NTFS ACLs belong to file objects rather than paths; the Everyone and hard-link boundaries are why the rung reports partial rather than full enforcement. Building directly on the raw ACL mechanism is the recorded design choice: it implements both confinement modes without the problems the rejected container options carry — see the [design note](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md) ([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) needs an OS floor of Windows 11 24H2 and wholesale host DACL writes for arbitrary-path reads; AppContainer cannot do arbitrary-path reads at all). ## Usage ```ts -import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() +const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape: read-only pairs with zero grants. workspace-write -// REQUIRES the workspace's write SID — the per-workspace identity. -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' }) +// match the grant shape. workspace-write requires distinct workspace and +// private-temp identities; pass tempDir: null to disable temp writes. +const sandbox = new AclSandbox({ + writableDirs: [workspaceRoot], + tempDir, + writeSid: workspaceWriteSid(workspaceRoot), + tempWriteSid: tempWriteSid(tempDir), + mode: 'workspace-write', +}) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure +rmSync(tempDir, { recursive: true, force: true }) ``` -A direct `AclSandbox` grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache) and the temp ACE revocably (dispose() revokes it, so an inheritable ACE never outlives the instance on the ambient temp root). The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. +A direct `AclSandbox` requires an explicit private temp directory (or `tempDir: null`; the ambient temp root is never an implicit grant), grants the workspace ACEs STANDING (dispose() leaves them — they are the cross-instance reuse cache), and grants the distinct temp SID revocably. The server-side reuse is the `AclWriteGrant` class: `add(path, standing)` per directory, `dispose()` revokes the revocable paths and frees the SID — see the runner contract below. Every Win32 API call in this package is checked; failures throw `Win32Error` carrying the API name, the exact Win32 code, the `FormatMessageW` system text, and the failing path/context. This is deliberate: the POC ignored every return value and, when `CreateRestrictedToken` failed, silently ran the child with the FULL unrestricted token (fail-open). This port fails closed by construction. ## The confinement runner The seam-facing shape is the **runner entry** (`./runner`), the argv-prefix wrapper `@deepseek-ai/dsh-sandbox-local` spawns in place of the caller's command — the same architecture as bwrap/landlock-run/sandbox-exec, so the sandbox seam's `confine()` contract needs no change. Stable argv contract: ```sh -node runner.js --workspace --temp --mode [--write-sid ] -- +node runner.js --workspace --temp --mode [--write-sid --temp-write-sid ] -- ``` -The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. +The runner creates the restricted token, spawns the wrapped argv under it with the caller's stdio passed straight through (the caller's pipes, made inheritable around the spawn — Node clears stdio inheritability at startup, which raw spawns must compensate for), wraps the child in a `KILL_ON_JOB_CLOSE` job (a dead runner kills the child), ignores its own console Ctrl+C so the child handles its own, mirrors the child's exit code, and revokes its self-managed temp grant on exit (workspace ACEs stand). Every runner-side failure prints `windows-acl-run: ` to stderr and exits 127 — the seam's `RUNNER_FAILURE_RULES` match that signature, so a runner refusal is never mistaken for a denial. -**Workspace grant reuse** (`--write-sid`): the write SID is DERIVED from the workspace path — no SID or temp-dir state is stored anywhere (the previous per-session random SID and its tamper surface are gone). The seam materializes the workspace ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache) and the temp ACE revocably (revoked on provider dispose), both lazily at the session's first confined execution. The session's private temp subdirectory is DERIVED from the session id + workspace (sha256, 16 hex) instead of stored: a resumed session derives the same directory and re-grants it (the exact-ACE skip keeps that O(1)), while a fork's different session id derives a fresh one. The directory is created EXCLUSIVELY — a pre-existing entry or a reparse point fails the first confined run loudly, so the grant never lands on a foreign object — and removed again on provider dispose. Under `--write-sid` the runner neither grants nor revokes (`manageDacls: false`) — the flag's presence marks the seam-managed contract, its value is the derived SID; without it (standalone use) the runner self-manages with the SAME derived SID (workspace ACEs standing, temp ACE revocable per call). Re-granting after a restart is idempotent: `grantWrite` reads the current DACL and SKIPS the `SetNamedSecurityInfoW` apply when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Standing ACEs from an unclean shutdown need no garbage collection — they ARE the cache; the same derived SID re-hits them forever. Known cost: materializing the grant on a big workspace tree blocks for the full eager propagation once per workspace per machine (the first confined write ever on this host). +**Workspace reuse and temp isolation**: the seam materializes the deterministic workspace SID's ACE STANDING (once per workspace per server lifetime, never revoked — it is the reuse cache), then creates a random private temp directory and distinct revocable SID for each live session/workspace pair. It passes both identities as the required `--write-sid`/`--temp-write-sid` pair; the runner verifies each against its owning path and neither grants nor revokes (`manageDacls: false`). A fork receives a different temp capability, and a fresh provider gives even the same resumed session a new path and SID, so crash residue is inert litter rather than a collision or inherited capability. Without the pair, `--temp` names a root: an agentless/standalone workspace-write runner creates a random private child, self-manages its temp SID, rewrites TMP/TEMP, and removes the child on exit. A workspace equal to or containing that root is rejected before any grant because its inheritable workspace ACE would otherwise authorize every private child; the direct API likewise rejects overlap between any writable root and the actual private temp directory. Re-granting the standing workspace ACE after a restart is idempotent: `grantWrite` reads the current DACL and skips `SetNamedSecurityInfoW` when the exact ACE already stands (that apply eagerly re-propagates the identical ACE across the whole tree — minutes on large workspaces). Known cost: the first grant on a big workspace tree blocks for that eager propagation once per workspace per machine. Modes (the token's restricting-SID list follows the mode; the keep-alive group is logon SID + Everyone in BOTH modes — early DLL init dies with `0xC0000142` and CNG crashes pwsh with `0xE0434352` without them): -- `workspace-write` (logon SID, Everyone, write SID): the workspace and the session's PRIVATE temp subdirectory carry the write-SID Write grant; every other write is denied by the token intersection. -- `read-only` (logon SID, Everyone — NO write SID): STRICT zero grants — nothing is writable. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL). +- `workspace-write` (logon SID, Everyone, workspace SID, temp SID): the workspace and the session's PRIVATE temp subdirectory carry separate Write grants; other ACL-addressable writes are denied except for the documented Everyone and hard-link boundaries. +- `read-only` (logon SID, Everyone — NO write SID): no explicit write-SID grants. The write SID stays OUT of the list on purpose: the standing workspace grant ACE from an earlier workspace-write period (a `/permission` downgrade, or a crash-resumed session) remains INERT under read-only because the write-restricted pass-2 check grants only what the restricting list carries — while the standing ACE keeps the re-upgrade free of re-propagation. Everyone's ambient rights remain the documented partial boundary. NUL writes are AMBIENT, not granted: the device DACL grants Everyone read+write+execute (`0x1201BF`), so openers whose mask fits it (cmd `> NUL`, node `\\.\NUL`) can write it in BOTH modes — the sandbox cannot zero-grant the NUL device while Everyone stays in the keep-alive group. `Set-Content NUL` fails in both modes (a PowerShell/.NET-layer effect, pinned by the read-only suite — the device DACL is not the denying party); PowerShell's `> $null` redirection keeps working (it discards without opening NUL). Authenticated Users is absent from BOTH lists — the WMI namespace security check fails (`0x80041003`), so CIM cmdlets and `Get-ComputerInfo` (which silently returns incomplete results rather than an error) are unavailable in EVERY confined mode, and the C:\-root tree-creation escape (standing `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — the model-facing surface documents that contract, not a prompt promise. INTERACTIVE/LOCAL are absent from BOTH lists too: the host's Public tree grants write to INTERACTIVE, so Public writes are denied — pinned by the runner's ambient-writable Public-probe regression (see the design note). -The `AclSandbox` class (`tempDir: null` disables the temp grant) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle. +The `AclSandbox` class (explicit private `tempDir` + `tempWriteSid`, or `tempDir: null` to disable temp writes) remains the programmatic API for direct spawns; `AclWriteGrant` is the server-side materialization half of the grant lifecycle. ## Header verification @@ -61,17 +72,19 @@ The koffi struct definitions assert their sizes against the probe at module load ## Verified boundaries (inherent to restricted tokens, not this port) +- **Everyone grants remain ambient write authority.** Everyone must stay in both restricting lists: removing it breaks early DLL initialization and CNG. An external NTFS object whose normal DACL grants Everyone a requested write right therefore clears both access checks and stays writable under both modes. The real runner suite provisions an external `Everyone:Modify` directory and pins that behavior; the provider reports `enforcement: 'partial'` so callers can reject or surface the weaker boundary. +- **Hard links are file-object aliases, not path aliases.** An inheritable workspace ACE propagated onto an existing NTFS hard link changes the one underlying file security descriptor, so the same object is writable through an external alias. Rejecting every multiply-linked workspace file is not viable for ordinary pnpm installations, which use hard links into their content-addressable store; the native runner suite pins the gap and the provider's partial report names its consequence. - **Writes are restricted; reads, network, and process visibility are not.** `WRITE_RESTRICTED` intersects write accesses only, so a confined child can read any caller-readable file and open sockets. `read-only` mode therefore cannot be expressed by this mechanism alone; pair it with a read-side policy or an AppContainer/`S-1-15-2` capability token for stronger confinement. - **Console isolation is unavailable.** Under the restricted token, children created with `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` die during DLL initialization with `STATUS_DLL_INIT_FAILED` (`0xC0000142`). The POC tried to fix this by adding the console logon SID (`S-1-2-1`) to the restricting list; on Windows 11 26200 `CreateWellKnownSid(WinLocalLogonSid)` fails with `ERROR_INVALID_PARAMETER` (87), the correct `WinConsoleLogonSid` yields a valid `S-1-2-1` but the child still dies, and the POC's final revision removed both the SID and console isolation. Children therefore share the host console; stdio redirection is pipe-based and unaffected. - **ACL grants are standing directory mutations.** They persist if the process dies mid-run; workspace ACEs are standing BY DESIGN (never revoked — the reuse cache), temp ACEs are revoked by `dispose()` (`init()` also revokes an already-applied temp grant when a later step fails). The POC's documented manual cleanup (`icacls /remove '*S-1-4-…'`) fails on this platform with `ERROR_NONE_MAPPED` (1332) — revoke through this module instead. An unclean shutdown needs no self-healing for the workspace ACE: the derived SID re-hits the standing ACE on the next provision (skipping the apply); the write-SID ACE never accumulates a second identity per restart because the identity IS the workspace. - **Granted directories must be caller-owned.** The owner's implicit `WRITE_DAC` is what lets the sandbox edit the DACL without elevation. -- **The temp grant follows `GetTempPathW`** — pass `tempDir` explicitly whenever possible. `GetTempPathW` reads the NATIVE environment block, which host runtimes that manage `process.env` through worker pools may not keep in sync (verified with vitest: a worker-side `process.env.TMP` change never reached the native block). The seam passes the session's PRIVATE subdirectory (`\dsh-<16 hex>` derived from the session id + workspace, created exclusively — a pre-existing entry or reparse point fails loudly); a defaulted grant landing on the real temp dir inherits `(OI)(CI)` over every subdirectory of temp, silently widening the allowlist — point it at a per-sandbox directory instead. -- **The confined child's temp root is private per session** (workspace-write + `--write-sid`): the runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to the session's private subdirectory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). Read-only leaves the ambient temp entries untouched — writes there are denied anyway. The subdirectory is removed on provider dispose; after a crash it may survive as plain `%TEMP%` litter until OS temp hygiene (or manual removal) reclaims it — a later resume then fails loudly at the exclusive creation. +- **The ambient temp root is never granted implicitly.** A direct `AclSandbox` workspace-write caller must supply an existing private `tempDir` plus its distinct `tempWriteSid`, or explicitly disable temp writes with `tempDir: null`. The actual temp directory must be disjoint from every writable root. The seam creates a random private directory; agentless runner calls treat `--temp` as the parent root and create their own random child, but reject a workspace equal to or containing that parent before any ACL mutation. +- **The confined child's temp capability is private per live session/workspace pair.** The runner rewrites TMP/TEMP via `SetEnvironmentVariableW` to that private directory before the spawn and the child inherits the rewritten block (bwrap `--tmpfs /tmp` semantics). The temp ACE and directory are removed on provider disposal, or after each agentless invocation. A crash can leave inert `%TEMP%` litter, but a resumed provider chooses a new random path and SID instead of colliding with or reauthorizing the residue. The native runner suite proves that two tokens sharing the same workspace SID cannot write one another's temp directories. - **`whoami` and token-inspection cmdlets fail under the restricted token.** `GetTokenInformation` on the duplicate is partially unavailable to the child, so `whoami /all` reports errors — diagnostic noise of the restriction scheme, not an operational failure; the denial surfaces that matter (file writes) are unaffected. ## Model Experience -Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection. +Indirectly, through [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md), [`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md), and their tools, which render this backend's partial-enforcement and denial facts (the confined stderr the tool layer classifies through `denialSignatures`) while the [`dsh-sandbox`](../sandbox/README.md) seam owns the `SANDBOX_UNAVAILABLE` text and runner selection. #### KV Cache effect @@ -80,12 +93,11 @@ None directly; the denial surface belongs to the tool layer. ## Known Limitations and Deferred Work - **One write allowlist per workspace** — the write SID is the unit of the allowlist and IS the workspace identity; reusing one sandbox instance across two workspaces widens both grants to both roots (the same SID would then name two roots). Create one instance per workspace root — the seam does exactly this, keyed by the workspace path. -- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure leaves a standing (but write-SID-only) temp ACE that this process's next `init()`/`dispose()` cycle or `icacls` (via the ACE, not the trustee name) can still remove. +- **Cleanup is best-effort by design** — `dispose()` attempts every temp revocation and aggregates failures into an `AggregateError`; a cleanup failure can leave the random directory and its temp-SID-only ACE behind. Once the process exits no future token carries that SID, so the residue is inert until OS temp hygiene or manual directory removal reclaims it. - **Standing workspace ACEs are invisible residue.** Renaming a workspace derives a new SID; the old ACEs on the old path stay (inert, write-SID-only). A future cleanup command may reap them; nothing re-propagates because of them. - **NULL-DACL directories are not identity-preserving under grant+revoke.** A directory with a NULL DACL (rare — Windows-created directories carry real DACLs) means "everyone full control"; `grantWrite` builds the new ACL from that null, and the revoke round-trip leaves an EMPTY (deny-all) DACL rather than the original NULL DACL. The POC shares the behavior; real workspace and temp directories carry real DACLs, so this stays a documented edge rather than a guarded path. - **Piped stdio capture is impossible for confined grandchildren (the named-pipe default SD template).** libuv's pipe stdio uses NAMED pipes; `CreateNamedPipeW` without security attributes installs the Win32 layer's user-mode default SD template (built by KernelBase — owner/SYSTEM/Admins full, Everyone/ANONYMOUS read-only, the fixed template [MS documents](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)) — NOT the token default DACL, which is what the kernel applies to a raw SD-null create — so the client-end open requests write access no restricting SID is granted: `spawn(..., { stdio: 'pipe' })` inside a confined process fails with EPERM, the POC-documented "no output redirection" boundary of WRITE_RESTRICTED tokens. Inherited (`inherit`/fd) and ignored (`ignore`) stdio spawns work, and anonymous pipes (CreatePipe — a token-default-DACL consumer, e.g. PowerShell pipelines) work because the restricted token's default DACL carries a full-access restricting-SID ACE (set at init). A confined process therefore cannot capture a grandchild's output through a pipe; tools that must capture output cannot run confined. -- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees plus the real temp root). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). If a workspace is huge, the first confined write on this host is correspondingly slow. -- **Resuming one session concurrently in two server processes fails the second at its first confined write.** Both processes derive the same private temp directory; the second one's exclusive creation hits the first one's directory and fails loudly. Single-writer session usage (the normal deployment) never sees this. +- **Grant materialization is an eager full-tree propagation.** `SetNamedSecurityInfoW` on a directory with inheritable ACEs walks every descendant immediately (NOT lazily per access — measured at tens of seconds on large workspace trees). The per-workspace identity pays it once per workspace per machine (lazily at the first confined execution ever, skipped entirely on every later provision when the exact ACE stands). Private temp directories start empty, so their distinct grant is cheap. If a workspace is huge, the first confined write on this host is correspondingly slow. - **Read-side confinement and network policy are out of scope** — `WRITE_RESTRICTED` intersects write accesses only; pair this backend with a read-side policy for stronger confinement. - **Wide-directory and FAT-volume warnings are deferred; FAT-class targets stay writable.** The UI-side warnings for granting unusually wide directories or FAT-class (non-ACL) volumes are not yet implemented, and a FAT volume as a grant ROOT simply fails the grant loudly (no ACL support). A FAT-class target OUTSIDE the granted roots is different: it has no security descriptors, so the restricted token's write check passes (Everyone sits in both lists) and such targets are writable under BOTH confined modes. FAT is treated as a legacy residue — unsupported and not engineered around; this warn-only posture is documented here rather than mitigated. -- **Both confined modes run `pwsh` in ConstrainedLanguage.** The restricted token trips PowerShell's lockdown detection, so under `read-only` AND `workspace-write` the language mode is ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Core cmdlets, core types (`[string]`, `[datetime]`, `[regex]`, `[guid]`), `-f` formatting, and property access keep working. The `pwsh` tool description teaches this contract to the model; `danger-full-access` calls run unconfined at FullLanguage. +- **PowerShell language mode differs by confined mode.** Under `read-only`, PowerShell cannot create its AppLocker probe files in temp and conservatively starts in ConstrainedLanguage: `Add-Type` (C# compile, P/Invoke), non-core .NET static calls (`[System.IO.*]::`, `[math]::`, `[Environment]::`), COM objects, and reflection fail with `Cannot create type` / `Cannot invoke method` ("only core types") errors, and `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` is refused. Under the shipped `workspace-write` path, the private-temp capability lets that probe complete, so pwsh stays in FullLanguage unless host-wide WDAC/AppLocker policy says otherwise; a direct `AclSandbox` configured with `tempDir: null` has no such guarantee and can fail the probe closed like read-only. This split is PowerShell startup behavior, not part of the ACL write boundary. The `pwsh` tool description teaches the shipped modes to the model; `danger-full-access` calls run unconfined at FullLanguage. diff --git a/packages/sandbox/sandbox-windows-acl/README.zh.md b/packages/sandbox/sandbox-windows-acl/README.zh.md index 9895449f6f..06121c3142 100644 --- a/packages/sandbox/sandbox-windows-acl/README.zh.md +++ b/packages/sandbox/sandbox-windows-acl/README.zh.md @@ -2,32 +2,43 @@ [English](README.md) | 中文 -面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。 +面向 [harness 沙盒 seam](../sandbox/) 的 Windows 写入限制沙盒后端:一个 Node.js/[koffi](https://koffi.dev/) 实现的、对 [huoyaoyuan/windows-acl-restrict-poc](https://github.com/huoyaoyuan/windows-acl-restrict-poc)(`10e4dfb`,修复后的修订)机制的移植,挂载为 [`@deepseek-ai/dsh-sandbox-local`](../sandbox-local/) 链中报告 `enforcement: 'partial'` 的 win32 一级(`workspace-write` / `read-only` 两种模式);Linux/macOS 后端在同一包中。 -一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 中加入一个写入 SID(`S-1-4-x-y`),该 SID 的 Write ACE 只存在于工作区与会话的私有临时目录上。写入 SID 是**按工作区**的身份,由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次——之后每次会话、调用、重启都命中精确 ACE 跳过——而不是每会话一次(见[隔离 runner](#the-confinement-runner))。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入——写入 SID 就是写入白名单,而它在系统其余位置不授予任何权限;令牌的写检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone——下文「模式」段是完整边界)。 +一句话机制:把调用者令牌复制为 `WRITE_RESTRICTED` 受限令牌,其 restricting SIDs 携带彼此独立的工作区能力与私有临时目录能力。工作区 SID 由规范工作区路径确定性派生(`workspaceWriteSid`),因此工作区根目录 ACE 每台机器每个工作区只物化一次,之后每次会话、调用或重启都命中精确 ACE 跳过。每个活跃的会话/工作区对则获得一个随机临时目录,以及一个从该路径派生的 SID(`tempWriteSid`),因此各会话共享预期的工作区权限,却不会继承彼此的临时目录权限。此后 Windows 只在「调用者正常权限」与「restricting SID 交集」同时允许时才放行写入。这些 SID 是主要白名单,在系统其余位置不授予任何权限;但该检查还会继承**其他** restricting SID 的环境写 ACE(保活组登录 SID + Everyone),而 NTFS ACL 属于文件对象而非路径。Everyone 与硬链接边界正是该档报告部分而非完整强制执行的原因。 直接构建在原生 ACL 机制上是记录在案的设计选择:它实现两种隔离模式,且不背负被否决的容器方案的问题——见[设计笔记](../../../.agents/notes/implemented/feature/2026-08-08-windows-acl-restricted-token-sandbox.md)([mxc](https://github.com/microsoft/mxc/blob/main/docs/process-container/os-version-support.md) 要求 Windows 11 24H2 的 OS 下限,且任意路径读取需要整体改写宿主 DACL;AppContainer 根本无法任意路径读取)。 ## 用法 ```ts -import { AclSandbox, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { AclSandbox, tempWriteSid, workspaceWriteSid } from '@deepseek-ai/dsh-sandbox-windows-acl' const workspaceRoot = process.cwd() +const tempDir = mkdtempSync(join(tmpdir(), 'dsh-')) // mode selects the token's restricting-SID list (see Modes below) and must -// match the grant shape: read-only pairs with zero grants. workspace-write -// REQUIRES the workspace's write SID — the per-workspace identity. -const sandbox = new AclSandbox({ writableDirs: [workspaceRoot], writeSid: workspaceWriteSid(workspaceRoot), mode: 'workspace-write' }) +// match the grant shape. workspace-write requires distinct workspace and +// private-temp identities; pass tempDir: null to disable temp writes. +const sandbox = new AclSandbox({ + writableDirs: [workspaceRoot], + tempDir, + writeSid: workspaceWriteSid(workspaceRoot), + tempWriteSid: tempWriteSid(tempDir), + mode: 'workspace-write', +}) await sandbox.init() // throws on ANY Win32 failure — never spawns unrestricted const child = sandbox.spawn({ command: 'pwsh', args: ['-NoProfile', '-Command', '...'], cwd: workspaceRoot }) const { stdout, stderr, exitCode } = await child.wait() sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing workspace ACE; reports every cleanup failure +rmSync(tempDir, { recursive: true, force: true }) ``` -直接使用 `AclSandbox` 时,工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),临时 ACE 以**可回收**方式授予(`dispose()` 撤销它,这样可继承 ACE 不会在环境临时根目录上比实例活得更久)。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 +直接使用 `AclSandbox` 时,必须显式提供私有临时目录(或通过 `tempDir: null` 禁用临时写入;环境临时根目录绝不会被隐式授权),工作区 ACE 以**常驻**方式授予(`dispose()` 保留它们——它们是跨实例的复用缓存),不同的临时 SID 则以**可回收**方式授予。服务端复用则是 `AclWriteGrant` 类:每个目录一次 `add(path, standing)`,`dispose()` 撤销可回收路径并释放 SID——见下方 runner 契约。本包中的每个 Win32 API 调用都有检查;失败抛出 `Win32Error`,携带 API 名、精确 Win32 错误码、`FormatMessageW` 系统文本和失败的路径/上下文。这是刻意的:POC 忽略每个返回值,当 `CreateRestrictedToken` 失败时用完整无限制令牌静默运行子进程(fail-open)。本移植从构造上 fail-closed。 @@ -36,20 +47,20 @@ sandbox.dispose() // revokes the revocable (temp) grant, keeps the standing work 面向 seam 的形态是 **runner 入口**(`./runner`):`@deepseek-ai/dsh-sandbox-local` 在调用者命令的位置 spawn 的 argv 前缀包装——与 bwrap/landlock-run/sandbox-exec 同一架构,因此沙盒 seam 的 `confine()` 契约无需改动。稳定的 argv 契约: ```sh -node runner.js --workspace --temp --mode [--write-sid ] -- +node runner.js --workspace --temp --mode [--write-sid --temp-write-sid ] -- ``` -runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。 +runner 创建受限令牌,在它之下 spawn 包装后的 argv,调用者的 stdio 直接透传(调用者的管道在 spawn 前后被设为可继承——Node 在启动时清除 stdio 可继承性,裸 spawn 必须补偿这一点),把子进程包进 `KILL_ON_JOB_CLOSE` job(runner 死亡则子进程死亡),忽略自身的控制台 Ctrl+C 让子进程自行处理,镜像子进程的退出码,并在退出时撤销其自行管理的临时授权(工作区 ACE 常驻)。每个 runner 侧失败都会向 stderr 打印 `windows-acl-run: ` 并以 127 退出——seam 的 `RUNNER_FAILURE_RULES` 匹配该签名,因此 runner 拒绝永远不会被误判为拒绝授权。 -**按工作区授权复用**(`--write-sid`):写入 SID 从工作区路径**派生**——任何地方都不存储 SID 或临时目录状态(先前每会话随机 SID 及其篡改面已移除)。seam 把工作区 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),把临时 ACE **可回收**物化(提供方 dispose 时撤销),两者都在会话首次受限执行时惰性进行。会话的私有临时子目录由会话 id + 工作区**派生**(sha256、16 位 hex)而非存储:恢复的会话派生同一个目录并重新授权(精确 ACE 跳过使这一步保持 O(1)),而 fork 的不同会话 id 会派生出一个全新的目录。该目录以**独占**方式创建——已存在条目或重解析点会让首次受限运行大声失败,因此授权永远不会落到外部对象上——并在提供方 dispose 时再次移除。传入 `--write-sid` 时 runner 既不授权也不回收(`manageDacls: false`)——该标志的存在标记 seam 管理的契约,其值即派生 SID;不传它(独立使用)时 runner 用**同一个**派生 SID 自行管理(工作区 ACE 常驻,临时 ACE 每次调用可回收)。重启后重新授权是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW` 的应用(该应用会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。异常关闭遗留的 ACE 无需垃圾回收——它们**就是**缓存;同一个派生 SID 永远重新命中它们。已知代价:在大型工作区树上物化授权会阻塞整次急切传播,每台机器每个工作区一次(该主机上的第一次受限写入)。 +**工作区复用与临时隔离**:seam 先把确定性工作区 SID 的 ACE **常驻**物化(每个工作区每服务器生命周期一次,绝不撤销——它就是复用缓存),再为每个活跃的会话/工作区对创建随机私有临时目录和不同的可回收 SID。它把两种身份作为必须成对出现的 `--write-sid`/`--temp-write-sid` 传入;runner 对照各自所属路径验证二者,既不授权也不撤销(`manageDacls: false`)。fork 获得不同的临时能力;即使恢复的是同一会话,新的提供方也会给出新的路径和 SID,因此崩溃残留只是失效垃圾,而非冲突或继承的能力。如果不带这一对标志,`--temp` 指定的是根目录:无 agent(智能体)/独立的 workspace-write runner 会创建随机私有子目录,自行管理其临时 SID,重写 TMP/TEMP,并在退出时移除该子目录。工作区若等于或包含该根目录,会在任何授权前被拒绝,因为否则其可继承的工作区 ACE 会向每个私有子目录授权;直接 API 同样拒绝任何可写根目录与实际私有临时目录重叠。重启后重新授权常驻工作区 ACE 是幂等的:`grantWrite` 读取当前 DACL,当完全相同的 ACE 已存在时跳过 `SetNamedSecurityInfoW`(应用该 ACE 会把相同的 ACE 急切地重新传播到整棵树——大型工作区上以分钟计)。已知代价:大型工作区树的首次授权会阻塞整次急切传播,每台机器每个工作区一次。 模式(令牌的 restricting-SID 列表随模式而变;保活组登录 SID + Everyone 在**两种**模式下都存在——没有它们早期 DLL 初始化会以 `0xC0000142` 死亡、CNG 会让 pwsh 以 `0xE0434352` 崩溃): -- `workspace-write`(登录 SID、Everyone、写入 SID):工作区与会话的**私有**临时子目录携带写入 SID 的 Write 授权;其余写全部被令牌交集拒绝。 -- `read-only`(登录 SID、Everyone——**不含**写入 SID):**严格零授权**——没有任何可写位置。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 +- `workspace-write`(登录 SID、Everyone、工作区 SID、临时 SID):工作区与会话的**私有**临时子目录分别携带 Write 授权;受 ACL 管辖的其他写入都会被拒绝,已记录的 Everyone 与硬链接边界除外。 +- `read-only`(登录 SID、Everyone——**不含**写入 SID):不存在显式的写入 SID 授权。写入 SID 有意留在列表**之外**:先前 workspace-write 时期留下的常驻授权 ACE(`/permission` 降级,或崩溃后恢复的会话)在 read-only 下保持**失效**,因为 write-restricted 的 pass-2 检查只授予 restricting 列表所携带的内容——而常驻 ACE 让重新升级免于重新传播。Everyone 的环境权限仍构成已记录的部分强制执行边界。NUL 写入是**环境性**的、不是被授权的:设备 DACL 授予 Everyone 读+写+执行(`0x1201BF`),因此访问掩码落在其内的打开者(cmd 的 `> NUL`、node 的 `\\.\NUL`)在**两种**模式下都能写——只要 Everyone 还在保活组里,沙盒就无法把 NUL 设备归零。`Set-Content NUL` 在两种模式下都失败(PowerShell/.NET 层效应,由 read-only 套件钉住——拒绝方不是设备 DACL);PowerShell 的 `> $null` 重定向不受影响(它直接丢弃、不打开 NUL)。 Authenticated Users 在**两种**列表中都不存在——WMI 命名空间安全检查失败(`0x80041003`),因此 CIM cmdlet 与 `Get-ComputerInfo`(它静默返回不完整结果而非报错)在**所有**受限模式下都不可用,且 C:\-root 树创建逃逸(常驻的 `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACE)在两种模式下都被关闭——面向模型的表面记录的是该契约,而不是提示词承诺。INTERACTIVE/LOCAL 在两种列表中同样不存在:宿主的 Public 树向 INTERACTIVE 授予写权限,因此 Public 写入被拒绝——由 runner 的环境可写 Public 探针回归测试钉住(见设计笔记)。 -`AclSandbox` 类(`tempDir: null` 禁用临时授权)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。 +`AclSandbox` 类(显式私有 `tempDir` + `tempWriteSid`,或用 `tempDir: null` 禁用临时写入)仍是直接 spawn 的编程 API;`AclWriteGrant` 是授权生命周期的服务端物化一半。 ## 头部验证 @@ -63,17 +74,19 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 ## 已验证边界(受限令牌固有,非本移植引入) +- **Everyone 授权仍是环境中的写权限来源。** Everyone 必须保留在两种 restricting 列表中:移除它会破坏早期 DLL 初始化与 CNG。因此,如果外部 NTFS 对象的正常 DACL 向 Everyone 授予所请求的写权限,它就会同时通过两次访问检查,并在两种模式下保持可写。真实 runner 套件配置一个外部 `Everyone:Modify` 目录并钉住该行为;提供方报告 `enforcement: 'partial'`,使调用方能够拒绝或向上暴露这项较弱的边界。 +- **硬链接是文件对象别名,而非路径别名。** 传播到已有 NTFS 硬链接上的可继承工作区 ACE 会修改底层同一文件的安全描述符,因此同一对象也可通过外部别名写入。拒绝工作区中的所有多链接文件不具可行性,因为普通 pnpm 安装会使用硬链接指向其内容寻址存储;原生 runner 套件钉住该缺口,提供方的部分强制执行报告则点明其后果。 - **写入受限;读取、网络与进程可见性不受限。** `WRITE_RESTRICTED` 只交叉检查写访问,因此受限子进程可以读取调用者可读的任何文件并打开套接字。`read-only` 模式因而不能仅靠该机制表达;将其与读侧策略或 AppContainer/`S-1-15-2` capability 令牌配对以获得更强隔离。 - **控制台隔离不可用。** 在受限令牌下,以 `CREATE_NO_WINDOW` / `CREATE_NEW_CONSOLE` 创建的子进程在 DLL 初始化期间以 `STATUS_DLL_INIT_FAILED`(`0xC0000142`)死亡。POC 尝试把控制台登录 SID(`S-1-2-1`)加入 restricting 列表来修复;在 Windows 11 26200 上 `CreateWellKnownSid(WinLocalLogonSid)` 以 `ERROR_INVALID_PARAMETER`(87)失败,正确的 `WinConsoleLogonSid` 能产出合法 `S-1-2-1` 但子进程仍然死亡,POC 的最终修订同时移除了该 SID 与控制台隔离。子进程因此共享宿主控制台;stdio 重定向走管道,不受影响。 - **ACL 授权是对真实目录的驻留改动。** 进程中途死亡会留下授权;工作区 ACE **按设计**常驻(绝不撤销——复用缓存),临时 ACE 由 `dispose()` 撤销(后续步骤失败时 `init()` 也会撤销已应用的临时授权)。POC 注释里的手工清理命令(`icacls /remove '*S-1-4-…'`)在本平台实测失败(`ERROR_NONE_MAPPED` 1332)——请通过本模块回收。工作区 ACE 在异常关闭后无需自愈:派生 SID 在下一次供给时重新命中常驻 ACE(跳过应用);写入 SID ACE 不会因每次重启而累积第二个身份,因为身份**就是**工作区。 - **被授权目录必须由调用者拥有。** 所有者的隐式 `WRITE_DAC` 是沙盒无需提权即可编辑 DACL 的原因。 -- **临时授权跟随 `GetTempPathW`**——尽可能显式传 `tempDir`。`GetTempPathW` 读取**原生**环境块,而通过 worker 池管理 `process.env` 的宿主运行时可能没有与之保持同步(vitest 实测:worker 侧的 `process.env.TMP` 变更从未到达原生块)。seam 传入会话的**私有**子目录(`\dsh-<16 hex>`,由会话 id + 工作区派生、独占创建——已存在条目或重解析点会大声失败);默认授权落在真实临时目录上会让 `(OI)(CI)` 继承到临时目录的每个子目录,静默扩大白名单——请改指向每个沙盒的目录。 -- **受限子进程的临时根目录按会话私有**(workspace-write + `--write-sid`):runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为会话的私有子目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。read-only 保持环境中的临时目录条目不动——那里的写入反正会被拒绝。子目录在提供方 dispose 时移除;崩溃后它可能作为普通 `%TEMP%` 垃圾存活,直到 OS 的临时目录卫生(或手动删除)将其回收——之后的恢复会在独占创建处大声失败。 +- **环境临时根目录绝不会被隐式授权。** 直接使用 `AclSandbox` 的 workspace-write 调用方必须提供一个已存在的私有 `tempDir` 及其不同的 `tempWriteSid`,或通过 `tempDir: null` 显式禁用临时写入。实际临时目录不得与任何可写根目录重叠。seam 会创建随机私有目录;无 agent runner 调用把 `--temp` 视为父根目录并自行创建随机子目录,但如果工作区等于或包含该父根目录,就会在任何 ACL 改动前拒绝调用。 +- **受限子进程的临时能力按每个活跃的会话/工作区对私有。** runner 在 spawn 之前用 `SetEnvironmentVariableW` 把 TMP/TEMP 改写为该私有目录,子进程继承改写后的环境块(bwrap `--tmpfs /tmp` 的语义)。临时 ACE 与目录会在提供方 dispose 时移除,或在每次无 agent 调用后移除。崩溃可能留下失效的 `%TEMP%` 垃圾,但恢复后的提供方会选择新的随机路径和 SID,而不会与残留发生冲突或重新向其授权。原生 runner 套件证明,共享同一工作区 SID 的两个令牌无法写入彼此的临时目录。 - **受限令牌下 `whoami` 与令牌检查 cmdlet 会失败。** 子进程对复制令牌的 `GetTokenInformation` 部分不可用,因此 `whoami /all` 报错——这是限制方案的诊断噪音,不是运行故障;真正重要的拒绝面(文件写入)不受影响。 ## Model Experience -间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的强制与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 +间接地通过 [`dsh-bash-sandbox`](../../bash/bash-sandbox/README.md)、[`dsh-pwsh-sandbox`](../../bash/pwsh-sandbox/README.md) 及其工具呈现:它们渲染此后端的部分强制执行与拒绝事实(工具层通过 `denialSignatures` 分类的受限 stderr),而 [`dsh-sandbox`](../sandbox/README.md) seam 拥有 `SANDBOX_UNAVAILABLE` 文本与 runner 选择。 #### KV Cache 影响 @@ -82,12 +95,11 @@ koffi 结构体定义在模块加载时对照探针断言其大小,因此头 ## Known Limitations and Deferred Work - **每个工作区一个写入白名单** —— 写入 SID 是白名单的基本单位,且**就是**工作区身份;同一沙盒实例跨两个工作区复用时,两个根目录会互相扩大授权面(同一个 SID 将命名两个根)。请按工作区根目录各建一个实例——seam 正是这样做的,以工作区路径为键。 -- **清理尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败只会留下仅含写入 SID 的临时 ACE,本进程下次 `init()`/`dispose()` 循环或 `icacls`(按 ACE 而非受托者名)仍可清除。 +- **清理按设计尽力而为** —— `dispose()` 会尝试全部临时撤销并把失败聚合为 `AggregateError`;清理失败可能留下随机目录及其仅含临时 SID 的 ACE。进程退出后,不会再有令牌携带该 SID,因此残留保持失效,直到 OS 临时目录卫生或手动移除目录将其回收。 - **常驻工作区 ACE 是不可见残留。** 工作区改名会派生新的 SID;旧路径上的旧 ACE 留在原地(失效、仅含写入 SID)。未来的清理命令可以回收它们;它们不会引起任何重新传播。 - **NULL-DACL 目录在 grant+revoke 往返下不保持身份。** 带 NULL DACL 的目录(罕见——Windows 创建的目录都带真实 DACL)意味着「所有人完全控制」;`grantWrite` 从该 null 构建新 ACL,撤销往返后留下的是 EMPTY(全部拒绝)DACL 而非原始 NULL DACL。POC 行为相同;真实工作区与临时目录都带真实 DACL,因此这仍是记录在案的边界情形而非守护路径。 - **受限孙进程的管道 stdio 捕获不可用(named pipe 的默认 SD 模板)。** libuv 的管道 stdio 用的是 NAMED pipe;不带安全属性调用 `CreateNamedPipeW` 时,其默认安全描述符不是内核的模板,而是 Win32 层在用户态安装的默认 SD 模板(由 KernelBase 构建——owner/SYSTEM/Admins 全权,Everyone/ANONYMOUS 只读,即 [MS 文档](https://learn.microsoft.com/en-us/windows/win32/ipc/named-pipe-security-and-access-rights)记载的固定模板)——**不是**令牌默认 DACL(后者才是内核在原始 SD-null 创建时应用的)——因此 client 端打开所请求的写访问没有任何 restricting SID 被授予:受限进程内 `spawn(..., { stdio: 'pipe' })` 以 EPERM 失败,这是 POC 记载的 WRITE_RESTRICTED「无法重定向输出」边界。继承(`inherit`/fd)与忽略(`ignore`)stdio 的 spawn 可用;匿名管道(CreatePipe——令牌默认 DACL 的消费者,例如 PowerShell 的管道)因受限令牌默认 DACL 携带 restricting SID 全权 ACE(init 时写入)而可用。受限进程因此无法用管道捕获孙进程输出;必须捕获输出的工具无法在受限下运行。 -- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒,加上真实临时根目录)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。如果工作区巨大,该主机上的第一次受限写入相应变慢。 -- **两个服务器进程并发恢复同一会话时,第二个会在其首次受限写入处失败。** 两个进程派生同一个私有临时目录;第二个的独占创建撞上第一个的目录并大声失败。单写者会话用法(常规部署)永远不会遇到。 +- **授权物化是急切的全树传播。** 在带可继承 ACE 的目录上调用 `SetNamedSecurityInfoW` 会立即遍历每个后代(**不是**按访问惰性进行——大型工作区树上实测数十秒)。按工作区身份每台机器每个工作区只付一次(在首次受限执行时惰性进行,之后每次供给在精确 ACE 常驻时完全跳过)。私有临时目录创建时为空,因此其独立授权开销很小。如果工作区巨大,该主机上的第一次受限写入相应变慢。 - **读侧隔离与网络策略不在范围内** —— `WRITE_RESTRICTED` 只交叉检查写访问;将此后端与读侧策略配对以获得更强隔离。 - **宽目录与 FAT 卷警告已推迟;FAT 类目标保持可写。** 对异常宽的目录或 FAT 类(非 ACL)卷的 UI 侧警告尚未实现,且 FAT 卷作为授权**根**只会大声失败(无 ACL 支持)。授权根**之外**的 FAT 类目标则不同:它没有安全描述符,因此受限令牌的写检查通过(Everyone 在两种列表中都在)——此类目标在**两种**受限模式下都可写。FAT 被视为遗留残留——不受支持、不围绕它设计;此处记录的是这种仅警告的立场,而非缓解措施。 -- **两种受限模式都运行 ConstrainedLanguage 的 `pwsh`。** 受限令牌会触发 PowerShell 的锁定检测,因此在 `read-only` **和** `workspace-write` 下语言模式都是 ConstrainedLanguage:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。核心 cmdlet、核心类型(`[string]`、`[datetime]`、`[regex]`、`[guid]`)、`-f` 格式化与属性访问保持可用。`pwsh` 工具描述向模型传授该契约;`danger-full-access` 调用不受限地在 FullLanguage 下运行。 +- **PowerShell 语言模式因受限模式而异。** 在 `read-only` 下,PowerShell 无法在临时目录中创建 AppLocker 探针文件,因此会保守地以 ConstrainedLanguage 启动:`Add-Type`(C# 编译、P/Invoke)、非核心 .NET 静态调用(`[System.IO.*]::`、`[math]::`、`[Environment]::`)、COM 对象与反射以 `Cannot create type` / `Cannot invoke method`(「only core types」)错误失败,且 `$ExecutionContext.SessionState.LanguageMode = 'FullLanguage'` 被拒绝。交付的 `workspace-write` 路径拥有私有临时目录能力,可使该探针完成,因此除非主机范围的 WDAC/AppLocker 策略另有规定,否则 pwsh 保持 FullLanguage;直接使用 `AclSandbox` 并配置 `tempDir: null` 时则没有这一保证,探针可能像 read-only 一样失败并按 fail-closed 处理。这一区别属于 PowerShell 启动行为,不是 ACL 写入边界的一部分。`pwsh` 工具描述向模型传授这些交付模式;`danger-full-access` 调用不受限地在 FullLanguage 下运行。 diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 2f13b71296..b2f0a2b089 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-sandbox-windows-acl", - "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with orphan-SID write allowlist) for the DeepSeek Harness sandbox seam", - "version": "0.0.1", - "private": true, + "description": "Windows ACL write-restriction sandbox backend (restricted-token spawn with capability-SID write allowlist) for the DeepSeek Harness sandbox seam", + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/sandbox/sandbox-windows-acl" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -26,12 +33,13 @@ "lib/index.js", "lib/invariant.js", "lib/runner.js", + "lib/types-*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "koffi": "^3.1.0" @@ -40,6 +48,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/sandbox/sandbox-windows-acl/src/acl.ts b/packages/sandbox/sandbox-windows-acl/src/acl.ts index ef787cc410..eded0f86a8 100644 --- a/packages/sandbox/sandbox-windows-acl/src/acl.ts +++ b/packages/sandbox/sandbox-windows-acl/src/acl.ts @@ -1,5 +1,5 @@ /** - * ACL editing helpers: grant/revoke the orphan write SID on a directory via + * ACL editing helpers: grant/revoke a capability SID on a directory via * SetEntriesInAclW + SetNamedSecurityInfoW (the same calls the POC uses, with * the failure handling the POC lacks). Every API call is checked and every * failure is reported with the API name, the exact Win32 code, the formatted @@ -39,7 +39,7 @@ export function buildExplicitAccess(sidPtr: NativePtr, mode: number, permissions entry.writeUInt32LE(abi.NO_MULTIPLE_TRUSTEE, 24) // Trustee.MultipleTrusteeOperation entry.writeUInt32LE(abi.TRUSTEE_IS_SID, 28) // Trustee.TrusteeForm entry.writeUInt32LE(abi.TRUSTEE_IS_UNKNOWN, 32) // Trustee.TrusteeType - entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the orphan SID + entry.writeBigUInt64LE(ptrAddress(sidPtr), 40) // Trustee.ptstrName = the capability SID return entry } @@ -181,16 +181,16 @@ function mergeAndApply( /** * True when the explicit DACL already carries the EXACT write grant this * module would add (Allow ACE, OI|CI inheritance, {@link abi.GRANT_MASK}, the - * orphan SID). Every field is read through koffi.decode at pointer offsets — + * capability SID). Every field is read through koffi.decode at pointer offsets — * no memcpy, no pointer arithmetic. The ACE's SID is INLINE (embedded in the * ACE after the 4-byte mask — there is no pointer to read; reading one * yields garbage addresses and crashed EqualSid, verified by gdb), so it is - * compared field-by-field against the orphan SID through bounded offset + * compared field-by-field against the capability SID through bounded offset * reads ({@link sameSidAt}). A malformed header reads as "no exact grant" * so the caller falls back to the merge-apply path, which owns the robust * failure handling. * @param oldAcl - the current explicit DACL pointer (from {@link readCurrentDacl}). - * @param sidPtr - the orphan write SID to match. + * @param sidPtr - the capability SID to match. * @returns whether the exact grant ACE is already present. */ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { @@ -213,7 +213,7 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { } /** - * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the orphan SID + * Grant `GRANT_MASK` (Write+Delete, displays as "Modify") to the capability SID * on `path`, inheriting to subcontainers and objects. Idempotent: when the * directory's current explicit DACL already carries the exact ACE (the * per-session grant surviving from a previous server lifetime), the @@ -226,7 +226,7 @@ function hasExactGrant(oldAcl: NativePtr, sidPtr: NativePtr): boolean { * precondition as the POC. * @param api - the binding table. * @param path - the directory whose DACL gains the grant (the workspace or temp root). - * @param sidPtr - the orphan write SID the ACE names. + * @param sidPtr - the capability SID the ACE names. */ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): void { withPathLock(api, path, () => { @@ -244,15 +244,15 @@ export function grantWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): } /** - * Remove every ACE for the orphan SID from the directory DACL (REVOKE_ACCESS + * Remove every ACE for the capability SID from the directory DACL (REVOKE_ACCESS * merge — other entries are preserved). Returns whether an ACE removal was * attempted (false when the directory carries no DACL at all). * * Runs under the per-path lock (the whole get-merge-set sequence); the * descriptor/ACL allocation contract lives on {@link readCurrentDacl}. * @param api - the binding table. - * @param path - the directory whose DACL loses the orphan-SID ACEs. - * @param sidPtr - the orphan write SID whose ACEs are removed. + * @param path - the directory whose DACL loses the capability-SID ACEs. + * @param sidPtr - the capability SID whose ACEs are removed. * @returns whether an ACE removal was attempted (false when the directory carries no DACL at all). */ export function revokeWrite(api: Win32Bindings, path: string, sidPtr: NativePtr): boolean { diff --git a/packages/sandbox/sandbox-windows-acl/src/grant.ts b/packages/sandbox/sandbox-windows-acl/src/grant.ts index 7cfd6e1a36..edb0345579 100644 --- a/packages/sandbox/sandbox-windows-acl/src/grant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/grant.ts @@ -1,12 +1,9 @@ /** - * Server-side per-session write grant: the ACE materialization half of the - * sandbox seam's per-session grant reuse. The seam (sandbox-local) holds ONE - * {@link AclWriteGrant} per session for the server process's lifetime — - * created lazily at the session's first confined execution, reused (never - * re-applied) for every later call, revoked on provider dispose. The durable - * half (the session's SID and paths surviving a restart) lives in the - * session log, owned by the seam; this module owns only the native half: the - * parsed SID pointer and the standing ACEs. + * Server-side write-grant materialization. The sandbox seam holds one + * standing workspace grant per workspace and one revocable temp grant per + * live session/workspace pair. Workspace identities survive by deterministic + * derivation and their standing ACE; temp identities derive from random + * private paths and are deliberately new after a restart. * * Fail-closed: `add` throws on any grant failure and the caller disposes the * instance (revoking every path granted so far); `dispose` revokes every @@ -19,7 +16,7 @@ import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32Sync } from '. import type { NativePtr, Win32Bindings } from './ffi.ts' /** - * One write SID's server-lifetime grant materialization: the parsed SID + * One write SID's provider-lifetime grant materialization: the parsed SID * pointer plus every directory whose DACL currently carries its ACE. * Workspace paths are added STANDING (their ACEs are the cross-session reuse * cache and outlive the grant — dispose() skips revoking them, or the next @@ -45,7 +42,7 @@ export class AclWriteGrant { /** * Parse the SID string and open the binding table (lazily, once per * server). Fail-closed: any failure throws — nothing is granted yet. - * @param writeSid - the orphan write SID string (`S-1-4-x-y`). + * @param writeSid - the workspace (`S-1-4-x-y`) or temp (`S-1-4-x-y-1`) capability SID string. * @param api - optional already-resolved bindings (tests). * @returns the ready grant (no ACEs yet). */ diff --git a/packages/sandbox/sandbox-windows-acl/src/index.ts b/packages/sandbox/sandbox-windows-acl/src/index.ts index 9a166fd85d..cf304b1904 100644 --- a/packages/sandbox/sandbox-windows-acl/src/index.ts +++ b/packages/sandbox/sandbox-windows-acl/src/index.ts @@ -2,10 +2,10 @@ * Windows ACL write-restriction sandbox backend for the DeepSeek Harness * sandbox seam. Mirrors the mechanism of github.com/huoyaoyuan/ * windows-acl-restrict-poc @ 10e4dfb (the fixed revision): a WRITE_RESTRICTED - * token whose restricting SIDs include a write SID (`S-1-4-x-y`) that only - * this sandbox adds to the target directories' DACLs — the intersection - * check then allows writes exactly where that SID has a Write ACE, and - * nowhere else the write SID is concerned (the token's write check ALSO + * token whose restricting SIDs include distinct workspace and temp write + * SIDs that this sandbox adds to their owning directories' DACLs — the + * intersection check then allows writes exactly where either capability has + * a Write ACE, and nowhere else those SIDs are concerned (the check ALSO * inherits the ambient write ACEs of the other restricting SIDs — the * keep-alive group logon SID + Everyone; Authenticated Users, INTERACTIVE, * and LOCAL are absent from both lists — see the seam's dual-list contract @@ -15,7 +15,9 @@ * path, so the workspace-root ACE materializes once per workspace per * machine and every later provision hits the exact-ACE skip — the * grant-reuse story the per-session random SID paid a full tree propagation - * per session for. Unlike the POC, every API failure throws with the API + * per session for. Each private temp directory instead receives its own SID, + * so sibling sessions sharing a workspace cannot enter one another's temp + * trees. Unlike the POC, every API failure throws with the API * name and exact Win32 code; a child is NEVER spawned unrestricted. * * Known boundaries (inherent to restricted tokens, not this port): @@ -24,15 +26,14 @@ * - console isolation is unavailable — children share the host console * (CREATE_NO_WINDOW / CREATE_NEW_CONSOLE children die with * STATUS_DLL_INIT_FAILED under the restriction); - * - the temp directory and every writable directory must be owned by the + * - the private temp directory and every writable directory must be owned by the * caller (owner-implicit WRITE_DAC); * - grants are standing ACE mutations on real directories. WORKSPACE grants * are deliberately never revoked — the ACE is the cross-session reuse * cache (revoking would force the next session to re-propagate the whole * tree). TEMP grants are revocable: dispose() removes them so a standing - * inheritable ACE never outlives its session's temp directory (an - * inheritable ACE on the ambient temp root would otherwise widen the - * SID's write reach to every future temp file). With `manageDacls: false` + * inheritable ACE never outlives its session's temp directory. The + * ambient temp root is never granted implicitly. With `manageDacls: false` * the CALLER owns the DACLs (the sandbox seam's grant reuse): * init()/dispose() skip grant/revoke entirely and the caller must not * revoke under live children. @@ -44,26 +45,27 @@ import { resolve } from 'node:path' import { grantWrite, revokeWrite } from './acl.ts' import { Win32Error } from './errors.ts' -import { allocPtrSlot, decodePtr, getTempPath, isNullPtr, throwLastError, win32 } from './ffi.ts' +import { allocPtrSlot, decodePtr, isNullPtr, throwLastError, win32 } from './ffi.ts' import type { NativePtr, Win32Bindings } from './ffi.ts' +import { assertPrivateTempDisjoint } from './path-boundary.ts' import { drainPipe, spawnSandboxed, spawnSandboxedInherited, waitForExit } from './spawn.ts' import { createRestrictedToken, findLogonSid, makeWellKnownSid, openCurrentProcessToken, setTokenDefaultDaclGrant } from './token.ts' import * as abi from './win32-abi.ts' export { quoteArg } from './spawn.ts' export { AclWriteGrant } from './grant.ts' -export { workspaceWriteSid } from './workspace-sid.ts' +export { assertTempRootOutsideWorkspace } from './path-boundary.ts' +export { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' export { Win32Error } from './errors.ts' -/** Construction options: the write allowlist, the optional temp grant, and the orphan SID identity. */ +/** Construction options: the workspace/temp allowlists and their distinct SID identities. */ export interface AclSandboxOptions { /** Directories the confined child may write into (must exist and be caller-owned). */ writableDirs: readonly string[] /** - * Temp directory to also grant; defaults to GetTempPathW() at init time. - * Pass null for read-only confinement: NO temp grant (strict zero grant on - * the filesystem; the NUL device stays ambient-writable via Everyone — see - * README). + * Existing private temp directory to grant. Workspace-write callers must + * pass it explicitly or pass null to disable temp writes; the ambient temp + * root is never an implicit grant. Read-only accepts only null/undefined. */ tempDir?: string | null /** @@ -74,6 +76,13 @@ export interface AclSandboxOptions { * outlives every instance and later provisions hit the exact-ACE skip. */ writeSid?: string + /** + * The private temp directory's write SID. Required whenever + * workspace-write grants a temp directory, absent otherwise. It must be + * distinct from {@link writeSid}, so sibling sessions sharing a workspace + * cannot use the standing workspace capability in one another's temp tree. + */ + tempWriteSid?: string /** * The file-effect mode this instance confines under — selects the * restricted token's restricting-SID list (I for read-only, J for @@ -85,7 +94,7 @@ export interface AclSandboxOptions { /** * Whether this instance owns its DACL grants (default true). False means * the CALLER has already materialized the ACEs (the sandbox seam's - * per-session grant reuse): init()/dispose() skip grant/revoke entirely — + * workspace/temp capability lifecycle): init()/dispose() skip grant/revoke entirely — * the caller holds the grants for its own lifetime and revokes them. */ manageDacls?: boolean @@ -123,6 +132,22 @@ export interface AclSandboxChild { wait(): Promise } +/** Free one optional SID while retaining a failure for best-effort sibling cleanup. */ +function freeSidBestEffort( + api: Win32Bindings, + sidPtr: NativePtr | undefined, + label: string, + failures: unknown[], +): void { + if (sidPtr === undefined) return + try { + const freed = api.localFree(sidPtr) + if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', label) + } catch (error) { + failures.push(error) + } +} + /** * One write-restricted sandbox instance: token + write-SID grants + spawn. * `init()` is fail-closed — any Win32 failure revokes the revocable (temp) @@ -135,8 +160,10 @@ export interface AclSandboxChild { export class AclSandbox { /** Absolute writable directories (constructor-validated). */ readonly writableDirs: string[] - /** The write SID string whose ACEs form the write allowlist (workspace-write only). */ + /** The workspace SID string whose ACEs form the workspace allowlist. */ readonly writeSid: string | undefined + /** The private temp directory's write SID (workspace-write with temp only). */ + readonly tempWriteSid: string | undefined /** The file-effect mode — the restricted token's restricting-SID list selection. */ readonly mode: 'read-only' | 'workspace-write' private readonly tempDirOption: string | null | undefined @@ -145,9 +172,10 @@ export class AclSandbox { private api: Win32Bindings | undefined private token: NativePtr | undefined private writeSidPtr: NativePtr | undefined - /** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SID. */ + private tempWriteSidPtr: NativePtr | undefined + /** The well-known/logon SID allocations init() makes; freed by dispose() alongside the write SIDs. */ private sidAllocations: NativePtr[] = [] - private grantedPaths: string[] = [] + private grantedPaths: Array<{ path: string; sidPtr: NativePtr }> = [] constructor(options: AclSandboxOptions) { this.mode = options.mode @@ -161,9 +189,28 @@ export class AclSandbox { }) this.tempDirOption = options.tempDir this.writeSid = options.writeSid + this.tempWriteSid = options.tempWriteSid if (this.mode === 'workspace-write' && this.writeSid === undefined) { throw new Error('AclSandbox workspace-write requires a write SID — derive it from the workspace via workspaceWriteSid()') } + if (this.mode === 'workspace-write' && this.tempDirOption === undefined) { + throw new Error('AclSandbox workspace-write requires an explicit private temp directory or null') + } + if (this.mode === 'read-only' && this.tempDirOption !== undefined && this.tempDirOption !== null) { + throw new Error('AclSandbox read-only does not accept a temp directory') + } + if (this.mode === 'read-only' && (this.writeSid !== undefined || this.tempWriteSid !== undefined)) { + throw new Error('AclSandbox read-only does not accept write SIDs') + } + if (this.mode === 'workspace-write' && this.tempDirOption !== null && this.tempWriteSid === undefined) { + throw new Error('AclSandbox workspace-write with temp requires a temp write SID — derive it via tempWriteSid()') + } + if (this.tempDirOption === null && this.tempWriteSid !== undefined) { + throw new Error('AclSandbox temp write SID requires a temp directory') + } + if (this.writeSid !== undefined && this.tempWriteSid === this.writeSid) { + throw new Error('AclSandbox workspace and temp write SIDs must be distinct') + } } /** Resolved temp directory (available after init; null when temp grants are disabled). */ @@ -171,56 +218,56 @@ export class AclSandbox { return this.tempDirResolved } - /** Create the restricted token and apply the orphan-SID grants. Idempotent-unsafe: once per instance. */ + /** Create the restricted token and apply the capability-SID grants. Idempotent-unsafe: once per instance. */ async init(): Promise { if (this.api !== undefined) throw new Error('AclSandbox is already initialized') const api = await win32() - const currentToken = openCurrentProcessToken(api) + let currentTokenOpen = true + let restrictedToken: NativePtr | undefined try { - // Read-only runs carry no write SID (its restricting list has no - // orphan): nothing to parse, nothing to grant. - let writeSidPtr: NativePtr | undefined - if (this.writeSid !== undefined) { + const parseSid = (sid: string): NativePtr => { const sidSlot = allocPtrSlot() - if (api.convertStringSidToSidW(this.writeSid, sidSlot) === 0) { - throwLastError(api, 'ConvertStringSidToSidW', this.writeSid) + if (api.convertStringSidToSidW(sid, sidSlot) === 0) { + throwLastError(api, 'ConvertStringSidToSidW', sid) } const parsedSid = decodePtr(sidSlot) - if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), this.writeSid) - this.writeSidPtr = parsedSid - writeSidPtr = parsedSid + if (parsedSid === null) throw new Win32Error('ConvertStringSidToSidW', api.getLastError(), sid) + return parsedSid } + this.writeSidPtr = this.writeSid === undefined ? undefined : parseSid(this.writeSid) + this.tempWriteSidPtr = this.tempWriteSid === undefined ? undefined : parseSid(this.tempWriteSid) - const tempDir = this.tempDirOption === null - ? null - : this.tempDirOption !== undefined ? this.tempDirOption : getTempPath(api) + const tempDir = this.mode === 'read-only' || this.tempDirOption === null ? null : this.tempDirOption + /* v8 ignore next -- constructor validation requires workspace-write to supply + an explicit temp directory or null; the other branches normalize to null. */ + if (tempDir === undefined) throw new Error('AclSandbox workspace-write temp directory was not resolved') if (tempDir !== null) { if (!existsSync(tempDir) || !statSync(tempDir).isDirectory()) { throw new Error(`AclSandbox temp dir does not exist or is not a directory: ${tempDir}`) } - this.tempDirResolved = tempDir + assertPrivateTempDisjoint(this.writableDirs, tempDir) } + this.tempDirResolved = tempDir // manageDacls: false — the caller (the sandbox seam's grant) already // materialized the ACEs; this instance must neither add nor remove any. // When this instance owns the DACLs, writableDir ACEs are STANDING (the // per-workspace reuse cache — dispose() never revokes them, or the next // provision would re-propagate the whole tree) and the temp ACE is - // REVOCABLE (dispose() removes it — an inheritable ACE on the ambient - // temp root must not outlive the instance, or it would widen the SID's - // write reach to every future temp file). + // REVOCABLE (dispose() removes it before the private directory is + // deleted; the ambient temp root is never granted). if (this.manageDacls) { - if (writeSidPtr !== undefined) { + if (this.writeSidPtr !== undefined) { for (const path of this.writableDirs) { - grantWrite(api, path, writeSidPtr) + grantWrite(api, path, this.writeSidPtr) } - if (tempDir !== null) { + if (tempDir !== null && this.tempWriteSidPtr !== undefined) { // Record BEFORE granting: grantWrite can throw after a successful // apply (a LocalFree failure), and the fail-closed catch must still // revoke that path (revoking an ungranted path is a no-op merge). - this.grantedPaths.push(tempDir) - grantWrite(api, tempDir, writeSidPtr) + this.grantedPaths.push({ path: tempDir, sidPtr: this.tempWriteSidPtr }) + grantWrite(api, tempDir, this.tempWriteSidPtr) } } } @@ -228,58 +275,63 @@ export class AclSandbox { this.sidAllocations.push(logonSid) const worldSid = makeWellKnownSid(api, abi.WinWorldSid) this.sidAllocations.push(worldSid) - const restricted = createRestrictedToken( - api, currentToken, logonSid, writeSidPtr, + const writeSids = [this.writeSidPtr, this.tempWriteSidPtr].filter((sid): sid is NativePtr => sid !== undefined) + restrictedToken = createRestrictedToken( + api, currentToken, logonSid, writeSids, { world: worldSid }, this.mode, ) + this.token = restrictedToken // The restricted token's default DACL still names only the user's // ambient SIDs — none of the restricting SIDs. Every NEW object the // confined process creates (anonymous stdio pipes, sync objects) takes // its DACL from that default, so the write pass-2 check would deny // pipe creation (ERROR_ACCESS_DENIED; Node EPERM) and break every // piped-stdio grandchild spawn. Merge a full-access ACE for a - // restricting SID (the write SID under workspace-write, Everyone under - // read-only): new-object creation stays gated by the parent object's - // DACL, while the new object's own DACL passes pass-2. - setTokenDefaultDaclGrant(api, restricted, writeSidPtr ?? worldSid) - this.token = restricted + // restricting SID (the PRIVATE temp SID when present, otherwise the + // workspace SID, or Everyone under read-only): new-object creation + // stays gated by the parent object's DACL, while the new object's own + // DACL passes pass-2. Choosing the temp SID prevents default-DACL + // objects in one session's temp tree from acquiring the shared + // workspace capability. + setTokenDefaultDaclGrant(api, restrictedToken, this.tempWriteSidPtr ?? this.writeSidPtr ?? worldSid) if (api.closeHandle(currentToken) === 0) throwLastError(api, 'CloseHandle', 'current process token') + currentTokenOpen = false this.api = api } catch (error) { - // Best-effort close on the failure path (last error already captured in `error`). - api.closeHandle(currentToken) - // FIXME(windows-acl): a failure after createRestrictedToken leaks the restricted - // token handle and the parsed write SID — this.api stays undefined, so dispose() - // early-returns and cannot clean them up. Close the token and free the write SID - // here (the hardening-followup rework already does both). - // Fail-closed cleanup: revoke the revocable (temp) grants and free the init SID - // allocations a failed init left behind. Standing workspace ACEs are NOT + // Fail-closed cleanup: never leave a revocable (temp) grant or SID + // allocation behind a failed init. Standing workspace ACEs are NOT // revoked — they are the intended end state (the reuse cache), not an // error artifact. const cleanupFailures: unknown[] = [] - const writeSidPtr = this.writeSidPtr - if (writeSidPtr !== undefined) { - for (const path of this.grantedPaths) { - try { - revokeWrite(api, path, writeSidPtr) - } catch (cleanupError) { - cleanupFailures.push(cleanupError) - } - } + if (currentTokenOpen && api.closeHandle(currentToken) === 0) { + cleanupFailures.push(new Win32Error('CloseHandle', api.getLastError(), 'current process token after init failure')) } - for (const sidPtr of this.sidAllocations.splice(0)) { + if (restrictedToken !== undefined && api.closeHandle(restrictedToken) === 0) { + cleanupFailures.push(new Win32Error('CloseHandle', api.getLastError(), 'restricted token after init failure')) + } + for (const grant of this.grantedPaths) { try { - const freed = api.localFree(sidPtr) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation') + revokeWrite(api, grant.path, grant.sidPtr) } catch (cleanupError) { cleanupFailures.push(cleanupError) } } + for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) { + freeSidBestEffort(api, sidPtr, label, cleanupFailures) + } + for (const sidPtr of this.sidAllocations.splice(0)) { + freeSidBestEffort(api, sidPtr, 'init SID allocation', cleanupFailures) + } + this.token = undefined + this.writeSidPtr = undefined + this.tempWriteSidPtr = undefined + this.tempDirResolved = undefined + this.grantedPaths = [] if (cleanupFailures.length > 0) { throw new AggregateError( [error, ...cleanupFailures], - `AclSandbox init failed and ${cleanupFailures.length} grant revocation(s) also failed`, + `AclSandbox init failed and ${cleanupFailures.length} cleanup operation(s) also failed`, ) } throw error @@ -345,23 +397,17 @@ export class AclSandbox { const api = this.api if (api === undefined) return const failures: unknown[] = [] - const writeSidPtr = this.writeSidPtr - if (writeSidPtr !== undefined) { - if (this.manageDacls) { - for (const path of this.grantedPaths) { - try { - revokeWrite(api, path, writeSidPtr) - } catch (error) { - failures.push(error) - } + if (this.manageDacls) { + for (const grant of this.grantedPaths) { + try { + revokeWrite(api, grant.path, grant.sidPtr) + } catch (error) { + failures.push(error) } } - try { - const freed = api.localFree(writeSidPtr) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'write SID') - } catch (error) { - failures.push(error) - } + } + for (const [label, sidPtr] of [['workspace write SID', this.writeSidPtr], ['temp write SID', this.tempWriteSidPtr]] as const) { + freeSidBestEffort(api, sidPtr, label, failures) } const token = this.token /* v8 ignore next -- init assigns this.api only after this.token, so an initialized instance always @@ -374,16 +420,12 @@ export class AclSandbox { } } for (const sidPtr of this.sidAllocations.splice(0)) { - try { - const freed = api.localFree(sidPtr) - if (!isNullPtr(freed)) throwLastError(api, 'LocalFree', 'init SID allocation') - } catch (error) { - failures.push(error) - } + freeSidBestEffort(api, sidPtr, 'init SID allocation', failures) } this.api = undefined this.token = undefined this.writeSidPtr = undefined + this.tempWriteSidPtr = undefined this.grantedPaths = [] if (failures.length > 0) { throw new AggregateError(failures, `AclSandbox dispose completed with ${failures.length} cleanup failure(s)`) diff --git a/packages/sandbox/sandbox-windows-acl/src/invariant.ts b/packages/sandbox/sandbox-windows-acl/src/invariant.ts index 35ea265a4b..95b0555d49 100644 --- a/packages/sandbox/sandbox-windows-acl/src/invariant.ts +++ b/packages/sandbox/sandbox-windows-acl/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox-windows-acl' diff --git a/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts b/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts new file mode 100644 index 0000000000..7c8dcb2596 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/src/path-boundary.ts @@ -0,0 +1,40 @@ +/** + * Canonical directory-boundary checks for the Windows ACL workspace and + * private-temp capabilities. + * @module @deepseek-ai/dsh-sandbox-windows-acl/path-boundary + */ + +import { realpathSync } from 'node:fs' +import { isAbsolute, relative, sep } from 'node:path' + +/** Whether `root` is the same canonical directory as `candidate` or contains it. */ +function containsDirectory(root: string, candidate: string): boolean { + const relation = relative(realpathSync.native(root), realpathSync.native(candidate)) + return relation === '' || (!isAbsolute(relation) && relation !== '..' && !relation.startsWith(`..${sep}`)) +} + +/** + * Reject a temp parent that is inside the workspace: every child created + * below it would inherit the standing workspace capability. + * @param workspaceRoot - the canonical workspace root that receives the standing ACE. + * @param tempRoot - the existing parent beneath which a private temp child would be created. + */ +export function assertTempRootOutsideWorkspace(workspaceRoot: string, tempRoot: string): void { + if (containsDirectory(workspaceRoot, tempRoot)) { + throw new Error(`Windows ACL temp root must be outside the workspace: workspace=${workspaceRoot}; temp=${tempRoot}`) + } +} + +/** + * Reject overlap between an actual private temp directory and any writable + * directory: either inheritance direction would merge the two capabilities. + * @param writableDirs - directories carrying the standing workspace capability. + * @param tempDir - the existing directory carrying the revocable temp capability. + */ +export function assertPrivateTempDisjoint(writableDirs: readonly string[], tempDir: string): void { + for (const writableDir of writableDirs) { + if (containsDirectory(writableDir, tempDir) || containsDirectory(tempDir, writableDir)) { + throw new Error(`AclSandbox private temp directory must be disjoint from writable directories: writable=${writableDir}; temp=${tempDir}`) + } + } +} diff --git a/packages/sandbox/sandbox-windows-acl/src/runner.ts b/packages/sandbox/sandbox-windows-acl/src/runner.ts index 93f8cfcc01..8d5fe35645 100644 --- a/packages/sandbox/sandbox-windows-acl/src/runner.ts +++ b/packages/sandbox/sandbox-windows-acl/src/runner.ts @@ -10,33 +10,29 @@ * keep the same contract): * [node, runner.js, '--workspace', , '--temp', , * '--mode', , - * ['--write-sid', ], '--', ] + * ['--write-sid', , + * '--temp-write-sid', ], '--', ] * * Modes: - * - workspace-write: the workspace and temp directories carry the orphan-SID - * Write grant; every other write is denied by the token intersection. - * - read-only: STRICT zero grants — no directory is writable, not even the - * NUL device (`> $null` fails with access denied); the restricting list - * carries no orphan SID, so a standing grant ACE from an earlier + * - workspace-write: the workspace and temp directories carry distinct + * capability-SID Write grants; other ACL-addressable writes are denied + * except for the documented Everyone and hard-link boundaries. + * - read-only: no capability-SID grants; the restricting list carries no + * capability SID, so a standing grant ACE from an earlier * workspace-write period stays inert. BOTH modes drop Authenticated Users * (CIM unavailable — documented in README) and INTERACTIVE/LOCAL (the * Public tree writes are denied); the two lists share the keep-alive group - * (logon SID, EVERYONE) and differ only by the orphan. + * (logon SID, EVERYONE) and differ only by the capabilities. * - * `--write-sid`: the seam's grant contract — the CALLER has already - * materialized the write-SID ACEs (the seam's workspace + private-temp - * grants, server lifetime) and owns their revocation, so the runner neither - * grants nor revokes (manageDacls: false). The carried SID is the - * per-workspace identity ({@link workspaceWriteSid}) — the seam derives it - * from the policy root; the flag's PRESENCE is the seam-managed marker (its - * value must equal the workspace-derived SID). Absent `--write-sid` - * (standalone/test use) the runner self-manages grants per invocation with - * the same workspace-derived SID (its workspace ACEs are standing — the - * reuse cache — and its temp ACE is revoked on exit). With `--write-sid` in - * workspace-write mode, the runner rewrites the TMP/TEMP entries of its OWN - * environment (SetEnvironmentVariableW) to the `--temp` directory — a - * PRIVATE per-session temp subdirectory the seam provisions (bwrap `--tmpfs - * /tmp` semantics) — and the child inherits the rewritten block (lpEnvironment + * `--write-sid` + `--temp-write-sid`: the seam's grant contract — the + * CALLER has already materialized distinct workspace and private-temp ACEs + * and owns their revocation, so the runner neither grants nor revokes + * (`manageDacls: false`). Both values are checked against their owning paths. + * Without the pair (standalone/agentless use), workspace-write treats + * `--temp` as a ROOT, creates a random private child directory, derives its + * own temp SID, and removes that directory after the child exits. In both + * flows the runner rewrites TMP/TEMP in its OWN environment to the private + * directory before spawning; the child inherits that block (`lpEnvironment` * NULL; an explicit block through koffi trips ERROR_INVALID_PARAMETER in * CreateProcessAsUserW, verified empirically). Read-only leaves the ambient * temp entries untouched (writes there are denied anyway). @@ -48,11 +44,12 @@ * @module @deepseek-ai/dsh-sandbox-windows-acl/runner */ -import { existsSync, statSync } from 'node:fs' +import { existsSync, mkdtempSync, rmSync, statSync } from 'node:fs' +import { join } from 'node:path' import { win32 } from './ffi.ts' -import { AclSandbox } from './index.ts' -import { workspaceWriteSid } from './workspace-sid.ts' +import { AclSandbox, assertTempRootOutsideWorkspace } from './index.ts' +import { tempWriteSid, workspaceWriteSid } from './workspace-sid.ts' const RUNNER_SIGNATURE = 'windows-acl-run' const RUNNER_FAILURE_EXIT = 127 @@ -70,6 +67,7 @@ interface ParsedArgs { temp: string mode: 'read-only' | 'workspace-write' writeSid: string | undefined + tempWriteSid: string | undefined command: string args: string[] } @@ -79,6 +77,7 @@ function parseArgs(raw: string[]): ParsedArgs { let temp: string | undefined let mode: string | undefined let writeSid: string | undefined + let parsedTempWriteSid: string | undefined let index = 0 for (; index < raw.length; index++) { const token = raw[index] @@ -94,6 +93,7 @@ function parseArgs(raw: string[]): ParsedArgs { case '--temp': temp = value; break case '--mode': mode = value; break case '--write-sid': writeSid = value; break + case '--temp-write-sid': parsedTempWriteSid = value; break default: fail(`unknown argument: ${token}`) } } @@ -103,7 +103,7 @@ function parseArgs(raw: string[]): ParsedArgs { const argv = raw.slice(index) const command = argv[0] if (command === undefined) fail('missing command after --') - return { workspace, temp, mode, writeSid, command, args: argv.slice(1) } + return { workspace, temp, mode, writeSid, tempWriteSid: parsedTempWriteSid, command, args: argv.slice(1) } } function requireDirectory(label: string, path: string): void { @@ -119,6 +119,17 @@ async function main(): Promise { requireDirectory('--workspace', parsed.workspace) requireDirectory('--temp', parsed.temp) + const seamManaged = parsed.writeSid !== undefined || parsed.tempWriteSid !== undefined + if (parsed.mode === 'read-only' && seamManaged) { + fail('read-only does not accept --write-sid or --temp-write-sid') + } + if (parsed.mode === 'workspace-write' && (parsed.writeSid === undefined) !== (parsed.tempWriteSid === undefined)) { + fail('workspace-write requires --write-sid and --temp-write-sid together') + } + if (parsed.mode === 'workspace-write') { + assertTempRootOutsideWorkspace(parsed.workspace, parsed.temp) + } + const api = await win32() // Ignore this process's own CTRL+C: the confined child (same console) keeps // handling its own; the runner must survive to revoke grants and mirror the @@ -127,36 +138,46 @@ async function main(): Promise { fail(`SetConsoleCtrlHandler failed (Win32 ${api.getLastError()})`) } - // The write SID is the per-workspace identity in BOTH flows; the flag's - // presence (seam-derived, or the self-managed derivation) selects who - // owns the DACLs below. - const writeSid = parsed.mode === 'workspace-write' ? parsed.writeSid ?? workspaceWriteSid(parsed.workspace) : undefined - const sandbox = new AclSandbox({ - writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], - tempDir: parsed.mode === 'workspace-write' ? parsed.temp : null, - mode: parsed.mode, - ...writeSid === undefined ? {} : { writeSid }, - // With --write-sid the seam owns the DACLs (workspace + private-temp - // grants): this invocation must neither add nor revoke ACEs. - manageDacls: parsed.writeSid === undefined, - }) - await sandbox.init() - - // The seam's per-session temp contract: under --write-sid, workspace-write - // children see the PRIVATE per-session temp subdirectory through TMP/TEMP - // (bwrap --tmpfs /tmp semantics). The runner rewrites its OWN environment - // (SetEnvironmentVariableW) and the child inherits the block; self-managed - // and read-only runs keep the ambient entries. - if (parsed.mode === 'workspace-write' && parsed.writeSid !== undefined) { - if (api.setEnvironmentVariableW('TMP', parsed.temp) === 0) { - fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`) - } - if (api.setEnvironmentVariableW('TEMP', parsed.temp) === 0) { - fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`) - } - } - + let ownedTempDir: string | undefined + let sandbox: AclSandbox | undefined + let initialized = false try { + let privateTempDir: string | null = null + let writeSid: string | undefined + let privateTempSid: string | undefined + if (parsed.mode === 'workspace-write') { + writeSid = workspaceWriteSid(parsed.workspace) + if (seamManaged) { + if (parsed.writeSid !== writeSid) fail('--write-sid does not match --workspace') + privateTempDir = parsed.temp + privateTempSid = tempWriteSid(privateTempDir) + if (parsed.tempWriteSid !== privateTempSid) fail('--temp-write-sid does not match --temp') + } else { + ownedTempDir = mkdtempSync(join(parsed.temp, 'dsh-')) + privateTempDir = ownedTempDir + privateTempSid = tempWriteSid(privateTempDir) + } + } + sandbox = new AclSandbox({ + writableDirs: parsed.mode === 'workspace-write' ? [parsed.workspace] : [], + tempDir: privateTempDir, + mode: parsed.mode, + ...writeSid === undefined ? {} : { writeSid }, + ...privateTempSid === undefined ? {} : { tempWriteSid: privateTempSid }, + manageDacls: !seamManaged, + }) + await sandbox.init() + initialized = true + + if (privateTempDir !== null) { + if (api.setEnvironmentVariableW('TMP', privateTempDir) === 0) { + fail(`SetEnvironmentVariableW TMP failed (Win32 ${api.getLastError()})`) + } + if (api.setEnvironmentVariableW('TEMP', privateTempDir) === 0) { + fail(`SetEnvironmentVariableW TEMP failed (Win32 ${api.getLastError()})`) + } + } + const child = sandbox.spawn({ command: parsed.command, args: parsed.args, @@ -166,10 +187,19 @@ async function main(): Promise { return result.exitCode } finally { // Cleanup failures must not mask the child's exit code: report and keep going. - try { - sandbox.dispose() - } catch (error) { - process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + if (initialized) { + try { + sandbox?.dispose() + } catch (error) { + process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + } + } + if (ownedTempDir !== undefined) { + try { + rmSync(ownedTempDir, { recursive: true, force: true }) + } catch (error) { + process.stderr.write(`${RUNNER_SIGNATURE}: cleanup: ${error instanceof Error ? error.message : String(error)}\n`) + } } } } diff --git a/packages/sandbox/sandbox-windows-acl/src/token.ts b/packages/sandbox/sandbox-windows-acl/src/token.ts index e6254acc03..abd0c73617 100644 --- a/packages/sandbox/sandbox-windows-acl/src/token.ts +++ b/packages/sandbox/sandbox-windows-acl/src/token.ts @@ -162,18 +162,19 @@ export interface RestrictingSidSet { * Create the write-restricted token with the mode-selected restricting list * (verified on Win11 26200, see the POC-worktree restrict-variant harness): * - read-only: [logon SID, EVERYONE] - * - workspace-write: [logon SID, EVERYONE, orphan] + * - workspace-write: [logon SID, EVERYONE, workspace SID, optional temp SID] * * The logon SID + EVERYONE keep-alive group is shared by both modes: early * DLL init dies with 0xC0000142 and CNG (`\Device\CNG` write trustee — - * pwsh crashes 0xE0434352) fails without them. The write SID joins ONLY + * pwsh crashes 0xE0434352) fails without them. The write SIDs join ONLY * workspace-write — read-only carries no write SID, so a standing grant ACE * from an earlier workspace-write period (a `/permission` mode downgrade, or * a crash-resumed session) stays INERT under read-only: the WRITE_RESTRICTED - * pass-2 check grants only what the restricting list carries, keeping - * read-only strictly zero-grant even with stale ACEs standing, while the - * unrevoked ACE keeps the re-upgrade free (the grant's exact-ACE skip — no - * re-propagation). Authenticated Users is absent from BOTH lists: the WMI + * pass-2 check grants only what the restricting list carries, keeping that + * workspace grant inert under read-only while the unrevoked ACE keeps the + * re-upgrade free (the grant's exact-ACE skip — no re-propagation). + * Everyone's own ambient grants remain the documented partial boundary. + * Authenticated Users is absent from BOTH lists: the WMI * namespace security check fails (0x80041003), so CIM is unavailable in * every confined mode, and the C:\-root tree-creation escape (standing * `AU:(AD)` + `AU:(OI)(CI)(IO)(M)` ACEs) is closed in both — documented in @@ -185,24 +186,25 @@ export interface RestrictingSidSet { * @param api - the binding table. * @param currentToken - the process token to restrict. * @param logonSid - the copied logon session SID. - * @param writeSid - the write SID forming the write allowlist (workspace-write only; absent under read-only). + * @param writeSids - the distinct write SIDs forming the workspace and + * optional temp allowlists (workspace-write only; empty under read-only). * @param known - the well-known SIDs entering the restricting list. - * @param mode - selects the restricting list (workspace-write adds the write SID). + * @param mode - selects the restricting list (workspace-write adds the capability SIDs). * @returns the restricted token handle. */ export function createRestrictedToken( api: Win32Bindings, currentToken: NativePtr, logonSid: NativePtr, - writeSid: NativePtr | undefined, + writeSids: readonly NativePtr[], known: RestrictingSidSet, mode: 'read-only' | 'workspace-write', ): NativePtr { const restrictingSids = buildRestrictingSids(mode === 'read-only' ? [logonSid, known.world] - : writeSid === undefined - ? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires the write SID') })() - : [logonSid, known.world, writeSid]) + : writeSids.length === 0 + ? (() => { throw new Error('createRestrictedToken: workspace-write restricting list requires at least one write SID') })() + : [logonSid, known.world, ...writeSids]) const tokenSlot = allocPtrSlot() const created = api.createRestrictedToken( currentToken, diff --git a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts index 8e85eced3c..5af4496af7 100644 --- a/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts +++ b/packages/sandbox/sandbox-windows-acl/src/win32-abi.ts @@ -63,7 +63,7 @@ export const FILE_DELETE_CHILD = 0x0040 // security boundary). /** * GRANT_MASK: FILE_GENERIC_WRITE minus READ_CONTROL plus DELETE and - * FILE_DELETE_CHILD — the write+delete access mask the orphan-SID ACEs grant + * FILE_DELETE_CHILD — the write+delete access mask the capability-SID ACEs grant * (displays as "Modify" in Explorer/icacls). WRITE_DAC/WRITE_OWNER are * deliberately excluded: they would let the confined child take ownership or * rewrite DACLs. diff --git a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts index db74893f36..db313ce092 100644 --- a/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts +++ b/packages/sandbox/sandbox-windows-acl/src/workspace-sid.ts @@ -8,8 +8,10 @@ * once per session. The SID's power is defined solely by the ACEs that name * it (which exist only on the workspace tree and the session's private temp * directory), and only tokens minted for that workspace carry it — the SID - * string itself is not a secret (the previous per-session SID was likewise - * logged in the plain). + * string itself is not a secret. Temporary directories use a separate, + * per-directory identity from {@link tempWriteSid}; sharing the workspace + * identity with temp would let sibling sessions write one another's temp + * trees. * * The input MUST be the canonical workspace path (`realpathSync.native` on * Windows — the sandbox-policy `resolveWorkspaceRoot` already applies it): @@ -26,7 +28,7 @@ import { createHash } from 'node:crypto' /** * Derive the workspace's write SID (`S-1-4-x-y`; subauthorities 30-bit, - * matching the orphan shape the token and ACE layers already carry). + * matching the workspace-capability shape the token and ACE layers carry). * @param workspaceRoot - the canonical workspace path. * @returns the SDDL string form. */ @@ -36,3 +38,17 @@ export function workspaceWriteSid(workspaceRoot: string): string { const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1 return `S-1-4-${first}-${second}` } + +/** + * Derive one private temp directory's write SID. The random directory path + * is the capability identity; a fixed third subauthority domain-separates + * the result from every two-subauthority workspace SID. + * @param tempDir - the private temp directory's absolute path. + * @returns the SDDL string form. + */ +export function tempWriteSid(tempDir: string): string { + const digest = createHash('sha256').update('temp\0', 'utf8').update(tempDir, 'utf8').digest() + const first = (digest.readUInt32LE(0) % (2 ** 30 - 1)) + 1 + const second = (digest.readUInt32LE(4) % (2 ** 30 - 1)) + 1 + return `S-1-4-${first}-${second}-1` +} diff --git a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts index 25abe14392..8d548a16df 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/acl.spec.ts @@ -9,7 +9,7 @@ * whose per-test lock file is removed in cleanup. */ -import { mkdtempSync, rmSync } from 'node:fs' +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it, vi } from 'vitest' @@ -120,7 +120,7 @@ describe.skipIf(!isWin32)('ACL editing', () => { const api = await win32() const dir = scratch() const usersSid = sidFromString(api, 'S-1-5-32-545') - const orphanSid = sidFromString(api, 'S-1-4-4242-1') + const capabilitySid = sidFromString(api, 'S-1-4-4242-1') try { // Install one explicit ACE (Users + benign read mask) with the // package's own bindings, exactly like a pre-existing explicit DACL @@ -137,37 +137,37 @@ describe.skipIf(!isWin32)('ACL editing', () => { expect(applyResult, `SetNamedSecurityInfoW setup (${applyResult})`).toBe(abi.ERROR_SUCCESS) expect(isNullPtr(freed)).toBe(true) - grantWrite(api, dir, orphanSid) - revokeWrite(api, dir, orphanSid) + grantWrite(api, dir, capabilitySid) + revokeWrite(api, dir, capabilitySid) const aces = readDirectAces(api, dir) expect(aces.some(ace => ace.sid === 'S-1-5-32-545')).toBe(true) // explicit ACE preserved expect(aces.some(ace => ace.sid === 'S-1-4-4242-1')).toBe(false) // orphan grant fully removed } finally { if (!isNullPtr(usersSid)) api.localFree(usersSid) - if (!isNullPtr(orphanSid)) api.localFree(orphanSid) + if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) } }) it('grantWrite is idempotent: a second grant over the standing exact ACE skips the SetNamedSecurityInfoW apply (no eager full-tree re-propagation)', async () => { const api = await win32() const dir = scratch() - const orphanSid = sidFromString(api, 'S-1-4-4242-2') + const capabilitySid = sidFromString(api, 'S-1-4-4242-2') const apply = vi.spyOn(api, 'setNamedSecurityInfoW') try { - grantWrite(api, dir, orphanSid) + grantWrite(api, dir, capabilitySid) expect(apply).toHaveBeenCalledTimes(1) // The exact ACE now stands (the per-session grant surviving from a // previous server lifetime): the second grant is a DACL read only. - grantWrite(api, dir, orphanSid) + grantWrite(api, dir, capabilitySid) expect(apply).toHaveBeenCalledTimes(1) const aces = readDirectAces(api, dir) expect(aces.filter(ace => ace.sid === 'S-1-4-4242-2')).toHaveLength(1) - revokeWrite(api, dir, orphanSid) + revokeWrite(api, dir, capabilitySid) expect(readDirectAces(api, dir).some(ace => ace.sid === 'S-1-4-4242-2')).toBe(false) } finally { apply.mockRestore() - if (!isNullPtr(orphanSid)) api.localFree(orphanSid) + if (!isNullPtr(capabilitySid)) api.localFree(capabilitySid) } }) @@ -192,21 +192,58 @@ describe.skipIf(!isWin32)('ACL editing', () => { const api = await win32() const workspaceDir = scratch() const tempDir = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspaceDir], tempDir, writeSid: 'S-1-4-9000-3', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ + writableDirs: [workspaceDir], + tempDir, + writeSid: 'S-1-4-9000-3', + tempWriteSid: 'S-1-4-9000-3-1', + mode: 'workspace-write', + }) await sandbox.init() sandbox.dispose() const workspaceAces = readDirectAces(api, workspaceDir) expect(workspaceAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(true) const tempAces = readDirectAces(api, tempDir) - expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3')).toBe(false) + expect(tempAces.some(ace => ace.sid === 'S-1-4-9000-3-1')).toBe(false) + }) + + it('rejects an overlapping private temp directory before applying either capability', async () => { + const workspaceDir = scratch() + const nestedTemp = join(workspaceDir, 'temp') + const writeSid = 'S-1-4-9000-30' + const privateTempSid = 'S-1-4-9000-30-1' + mkdirSync(nestedTemp) + const sandbox = new AclSandbox({ + writableDirs: [workspaceDir], + tempDir: nestedTemp, + writeSid, + tempWriteSid: privateTempSid, + mode: 'workspace-write', + }) + + await expect(sandbox.init()).rejects.toThrow(/private temp directory must be disjoint/u) + const api = await win32() + expect(readDirectAces(api, workspaceDir).some(ace => ace.sid === writeSid)).toBe(false) + expect(readDirectAces(api, nestedTemp).some(ace => ace.sid === privateTempSid)).toBe(false) }) it('workspace-write without a write SID fails at construction; the token layer guards the same contract', () => { const dir = scratch() expect(() => new AclSandbox({ writableDirs: [dir], tempDir: null, mode: 'workspace-write' })) .toThrow(/requires a write SID/) - expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, undefined, { world: 0n as never }, 'workspace-write')) - .toThrow(/requires the write SID/) + expect(() => new AclSandbox({ writableDirs: [dir], writeSid: 'S-1-4-1-1', mode: 'workspace-write' })) + .toThrow(/requires an explicit private temp directory or null/) + expect(() => new AclSandbox({ writableDirs: [dir], tempDir: dir, writeSid: 'S-1-4-1-1', mode: 'workspace-write' })) + .toThrow(/requires a temp write SID/) + expect(() => new AclSandbox({ + writableDirs: [dir], + tempDir: dir, + writeSid: 'S-1-4-1-1', + tempWriteSid: 'S-1-4-1-1', + mode: 'workspace-write', + })).toThrow(/must be distinct/) + expect(() => createRestrictedToken({} as never, 0n as never, 0n as never, [], { world: 0n as never }, 'workspace-write')) + .toThrow(/requires at least one write SID/) }) it('the per-path lock is exclusive: a second immediate lock attempt fails with ERROR_LOCK_VIOLATION until release', async () => { diff --git a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts index 77e931499d..c171d30084 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/index-failure-paths.spec.ts @@ -59,8 +59,8 @@ function scratch(): string { } /** - * The stub the whole happy pipeline needs: token opening, write-SID parse, - * workspace+temp grants, logon-SID scan, well-known SID, restricted token, + * The stub the whole happy pipeline needs: token opening, capability-SID + * parsing, workspace+temp grants, logon-SID scan, well-known SID, restricted token, * default-DACL merge, piped/inherited spawns, drains, and exit waits all * succeed. Every test flips one call per branch. */ @@ -186,6 +186,28 @@ describe('AclSandbox constructor validation', () => { expect(sandbox.mode).toBe('read-only') expect(sandbox.tempDir).toBeUndefined() }) + + it('rejects temp authority under read-only', () => { + const workspace = scratch() + const temp = scratch() + expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: temp, mode: 'read-only' })) + .toThrow(/read-only does not accept a temp directory/u) + expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-1', mode: 'read-only' })) + .toThrow(/read-only does not accept write SIDs/u) + expect(() => new AclSandbox({ writableDirs: [workspace], tempDir: null, tempWriteSid: 'S-1-4-9000-1-1', mode: 'read-only' })) + .toThrow(/read-only does not accept write SIDs/u) + }) + + it('rejects a temp SID when temp writes are disabled', () => { + const workspace = scratch() + expect(() => new AclSandbox({ + writableDirs: [workspace], + tempDir: null, + writeSid: 'S-1-4-9000-2', + tempWriteSid: 'S-1-4-9000-2-1', + mode: 'workspace-write', + })).toThrow(/temp write SID requires a temp directory/u) + }) }) describe('AclSandbox init', () => { @@ -193,17 +215,22 @@ describe('AclSandbox init', () => { const { setNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() const temp = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-1', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ + writableDirs: [workspace], + tempDir: temp, + writeSid: 'S-1-4-9000-1', + tempWriteSid: 'S-1-4-9000-1-1', + mode: 'workspace-write', + }) await sandbox.init() expect(sandbox.tempDir).toBe(resolve(temp)) expect(setNamedSecurityInfoW).toHaveBeenCalledTimes(2) }) - it('defaults the temp dir to GetTempPathW when no tempDir option is given', async () => { + it('requires an explicit private temp directory or null under workspace-write', () => { const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' }) - await sandbox.init() - expect(sandbox.tempDir).toBe(tmpdir().replace(/[\\/]$/u, '')) + expect(() => new AclSandbox({ writableDirs: [workspace], writeSid: 'S-1-4-9000-2', mode: 'workspace-write' })) + .toThrow(/requires an explicit private temp directory or null/u) }) it('applies no grants when the temp dir option is null', async () => { @@ -216,7 +243,13 @@ describe('AclSandbox init', () => { it('rejects a temp dir that does not exist', async () => { const workspace = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: join(scratch(), 'missing'), writeSid: 'S-1-4-9000-4', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ + writableDirs: [workspace], + tempDir: join(scratch(), 'missing'), + writeSid: 'S-1-4-9000-4', + tempWriteSid: 'S-1-4-9000-4-1', + mode: 'workspace-write', + }) await expect(sandbox.init()).rejects.toThrow(/temp dir does not exist/u) }) @@ -263,18 +296,31 @@ describe('AclSandbox init', () => { await expect(sandbox.init()).rejects.toBeInstanceOf(Win32Error) }) - it('reports a failed close of the current process token', async () => { - const { closeHandle } = state.stubs as HappyStubs + it('aggregates failed current and restricted token closes after init', async () => { + const { closeHandle, createRestrictedToken } = state.stubs as HappyStubs const workspace = scratch() const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: null, writeSid: 'S-1-4-9000-9', mode: 'workspace-write' }) + const restrictedToken = 99n + createRestrictedToken.mockImplementation(( + _existing: unknown, _flags: unknown, _dc: unknown, _ds: unknown, _pc: unknown, _pd: unknown, + _rc: unknown, _rs: unknown, slot: NativePtr, + ) => { + koffi.encode(slot, PVOID, restrictedToken) + return 1 + }) // fresh() hands out 1n to OpenProcess and 2n to OpenProcessToken; the // token-layer close of 1n succeeds and init's close of 2n fails. - closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n ? 0 : 1)) + closeHandle.mockImplementation((handle: NativePtr) => (handle === 2n || handle === restrictedToken ? 0 : 1)) // The failure lands after this.token is stored but before this.api is - // assigned; the catch drains the SID allocations and rethrows the - // original error. (The stored restricted token and parsed write SID leak - // until process exit — see the FIXME in init's catch.) - await expect(sandbox.init()).rejects.toMatchObject({ api: 'CloseHandle' }) + // assigned. Cleanup retries the still-open handle and reports both close + // failures plus the restricted-token close after releasing parsed SIDs. + await expect(sandbox.init()).rejects.toMatchObject({ + errors: [ + { api: 'CloseHandle' }, + { api: 'CloseHandle' }, + { api: 'CloseHandle' }, + ], + }) }) it('revokes the revocable grants and aggregates cleanup failures when the token pipeline fails', async () => { @@ -296,8 +342,15 @@ describe('AclSandbox init', () => { koffi.encode(descriptor, PVOID, 0n) return 0 }) - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-10', mode: 'workspace-write' }) - await expect(sandbox.init()).rejects.toThrow(/3 grant revocation\(s\) also failed/u) + const sandbox = new AclSandbox({ + writableDirs: [workspace], + tempDir: temp, + writeSid: 'S-1-4-9000-10', + tempWriteSid: 'S-1-4-9000-10-1', + mode: 'workspace-write', + }) + await expect(sandbox.init()).rejects.toThrow(/5 cleanup operation\(s\) also failed/u) + expect(sandbox.tempDir).toBeUndefined() }) }) @@ -354,7 +407,13 @@ describe('AclSandbox dispose', () => { const { getNamedSecurityInfoW } = state.stubs as HappyStubs const workspace = scratch() const temp = scratch() - const sandbox = new AclSandbox({ writableDirs: [workspace], tempDir: temp, writeSid: 'S-1-4-9000-16', mode: 'workspace-write' }) + const sandbox = new AclSandbox({ + writableDirs: [workspace], + tempDir: temp, + writeSid: 'S-1-4-9000-16', + tempWriteSid: 'S-1-4-9000-16-1', + mode: 'workspace-write', + }) await sandbox.init() getNamedSecurityInfoW.mockReturnValue(2) expect(() => { sandbox.dispose() }).toThrow(/1 cleanup failure/u) diff --git a/packages/sandbox/sandbox-windows-acl/tests/path-boundary.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/path-boundary.spec.ts new file mode 100644 index 0000000000..59d8297280 --- /dev/null +++ b/packages/sandbox/sandbox-windows-acl/tests/path-boundary.spec.ts @@ -0,0 +1,65 @@ +/** Canonical path-overlap checks that keep workspace and temp capabilities separate. */ + +import { mkdirSync, mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { afterEach, describe, expect, it } from 'vitest' + +import { assertPrivateTempDisjoint, assertTempRootOutsideWorkspace } from '../src/path-boundary.ts' + +describe('Windows ACL temp path boundary', () => { + const scratchDirs: string[] = [] + + afterEach(() => { + for (const dir of scratchDirs.splice(0)) rmSync(dir, { recursive: true, force: true }) + }) + + function scratch(): string { + const dir = mkdtempSync(join(tmpdir(), 'dsh-acl-boundary-')) + scratchDirs.push(dir) + return dir + } + + it('rejects a temp root equal to or below the workspace', () => { + const workspace = scratch() + const nested = join(workspace, 'temp') + mkdirSync(nested) + + expect(() => { + assertTempRootOutsideWorkspace(workspace, workspace) + }).toThrow(/temp root must be outside the workspace/u) + expect(() => { + assertTempRootOutsideWorkspace(workspace, nested) + }).toThrow(/temp root must be outside the workspace/u) + }) + + it('accepts a temp parent above the workspace because a fresh child is a sibling', () => { + const tempRoot = scratch() + const workspace = join(tempRoot, 'workspace') + mkdirSync(workspace) + + expect(() => { + assertTempRootOutsideWorkspace(workspace, tempRoot) + }).not.toThrow() + }) + + it('requires an actual private temp directory to be disjoint in either direction', () => { + const root = scratch() + const workspace = join(root, 'workspace') + const nestedTemp = join(workspace, 'temp') + const siblingTemp = join(root, 'sibling-temp') + mkdirSync(workspace) + mkdirSync(nestedTemp) + mkdirSync(siblingTemp) + + expect(() => { + assertPrivateTempDisjoint([workspace], nestedTemp) + }).toThrow(/must be disjoint/u) + expect(() => { + assertPrivateTempDisjoint([nestedTemp], workspace) + }).toThrow(/must be disjoint/u) + expect(() => { + assertPrivateTempDisjoint([workspace], siblingTemp) + }).not.toThrow() + }) +}) diff --git a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts index 0825695438..117052da1c 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/probe.spec.ts @@ -6,10 +6,10 @@ * WRITE_RESTRICTED token intersects write accesses only. * * The escape target sits in its own scratch dir under the system temp - * directory, OUTSIDE both granted trees: tempDir is passed EXPLICITLY (never - * defaulted through GetTempPathW, whose grant would inherit (OI)(CI) over the - * whole real temp tree) and the writable dir is a separate mkdtemp directory - * that contains neither sibling. Nothing under the user profile is touched. + * directory, OUTSIDE both granted trees: tempDir is an explicit private + * mkdtemp directory (the API never grants the ambient temp root implicitly), + * and the writable dir is a separate mkdtemp directory that contains neither + * sibling. Nothing under the user profile is touched. */ import { execFileSync } from 'node:child_process' @@ -47,11 +47,15 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () secretFile = join(scratchRoot, 'secret.txt') writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') escapeFile = join(scratchRoot, 'escaped.txt') - // tempDir is passed explicitly: GetTempPathW reads the native environment - // block, which host runtimes (vitest worker pools) may not keep in sync - // with process.env — and a real-temp grant would inherit over every - // temp subdirectory, including this test's scratch dir. - sandbox = new AclSandbox({ writableDirs: [writableDir], tempDir: isolatedTemp, writeSid: 'S-1-4-9000-4', mode: 'workspace-write' }) + // The direct API requires this explicit private temp directory and its + // own SID; it never widens the grant over the ambient temp root. + sandbox = new AclSandbox({ + writableDirs: [writableDir], + tempDir: isolatedTemp, + writeSid: 'S-1-4-9000-4', + tempWriteSid: 'S-1-4-9000-4-1', + mode: 'workspace-write', + }) await sandbox.init() }) @@ -91,7 +95,16 @@ describe.skipIf(!isWin32 || !pwshAvailable())('AclSandbox write restriction', () it('fails closed when the write SID cannot be parsed (no unrestricted fallback)', async () => { // A malformed SID makes ConvertStringSidToSidW fail; init must throw // before any grant is applied and never spawn unrestricted. - const broken = new AclSandbox({ writableDirs: [writableDir], writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) + const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) }, 15_000) + + it('failed init clears provisional temp state before a retry', async () => { + const broken = new AclSandbox({ writableDirs: [writableDir], tempDir: null, writeSid: 'S-1-4-abc-1', mode: 'workspace-write' }) + const provisionalState = broken as unknown as { tempDirResolved: string | undefined } + provisionalState.tempDirResolved = isolatedTemp + + await expect(broken.init()).rejects.toThrow(/ConvertStringSidToSidW/u) + expect(broken.tempDir).toBeUndefined() + }, 15_000) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts index 2557b1f43b..636c19986a 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/provider-chain.spec.ts @@ -9,7 +9,7 @@ import { tmpdir } from 'node:os' import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SandboxPolicy } from '@deepseek-ai/dsh-sandbox' import { LocalSandboxProvider } from '@deepseek-ai/dsh-sandbox-local' @@ -25,7 +25,7 @@ async function setup(internals: LocalSandboxProvider['internals']) { } describe('windows-acl win32 chain (LocalSandboxProvider)', () => { - it('workspace-write: runner argv prefix, explicit temp, mode flag, full enforcement, ACL denial dialect', async () => { + it('agentless workspace-write: runner argv prefix, temp root, mode flag, partial enforcement, ACL denial dialect', async () => { const probeWindowsAcl = vi.fn(() => true) const sandbox = await setup({ platform: 'win32', @@ -41,7 +41,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => { '--', 'pwsh', '/Command', 'x', ]) - expect(confined.enforcement).toBe('full') + expect(confined.enforcement).toBe('partial') expect(confined.denialSignatures).toEqual(['access is denied', 'access to the path', 'permission denied']) expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) // A sole candidate is selected unprobed. @@ -52,7 +52,7 @@ describe('windows-acl win32 chain (LocalSandboxProvider)', () => { const sandbox = await setup({ platform: 'win32', windowsAclRunnerArgs: ['node', 'windows-acl-runner.js'] }) const confined = sandbox.confine(['true'], RO) expect(confined.argv.slice(-4)).toEqual(['--mode', 'read-only', '--', 'true']) - expect(confined.enforcement).toBe('full') + expect(confined.enforcement).toBe('partial') expect(confined.runnerFailureRules).toEqual([{ allowedExitCodes: [127], fatalSignatures: ['windows-acl-run: '] }]) }) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts index 30229d6206..19dfdaf106 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/runner.spec.ts @@ -6,14 +6,14 @@ */ import { spawnSync } from 'node:child_process' -import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' +import { existsSync, linkSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import { resolvePwshPath } from '@deepseek-ai/dsh-pwsh-local' -import { AclWriteGrant } from '../src/index.ts' +import { AclWriteGrant, tempWriteSid, workspaceWriteSid } from '../src/index.ts' const isWin32 = process.platform === 'win32' const runnerEntry = fileURLToPath(new URL('../src/runner.ts', import.meta.url)) @@ -38,6 +38,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { let isolatedTemp!: string let secretFile!: string let escapeFile!: string + let worldWritableDir!: string // The ambient-writable probe target: a subdirectory of C:\Users\Public. // INTERACTIVE/LOCAL are absent from BOTH restricting lists, so the Public // tree's INTERACTIVE grant must NOT satisfy the write check — the ambient @@ -54,6 +55,12 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { secretFile = join(scratchRoot, 'secret.txt') writeFileSync(secretFile, 'top secret - must stay readable to prove the read boundary') escapeFile = join(scratchRoot, 'escaped.txt') + worldWritableDir = join(scratchRoot, 'world-writable') + mkdirSync(worldWritableDir) + const worldGrant = spawnSync('icacls', [worldWritableDir, '/grant', '*S-1-1-0:(OI)(CI)(M)'], { encoding: 'utf8' }) + if (worldGrant.status !== 0) { + throw new Error(`icacls Everyone grant failed: ${worldGrant.stdout}\n${worldGrant.stderr}`) + } try { publicProbeDir = mkdtempSync(join(process.env.PUBLIC ?? 'C:\\Users\\Public', 'dsh-acl-public-')) } catch { @@ -70,12 +77,13 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { it('workspace-write: the confined child writes granted directories only', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", - // The restricted token puts pwsh into ConstrainedLanguage in BOTH modes - // (documented Known Limitation) — pinned here so a token change that - // silently restores FullLanguage is caught. + // The private-temp capability lets PowerShell complete its startup + // AppLocker probe, so without a host policy workspace-write stays in + // FullLanguage. Read-only cannot create those scratch files and fails + // that probe closed to ConstrainedLanguage (pinned below). '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', `try{Set-Content -Path '${writableDir}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, - `try{Set-Content -Path '${isolatedTemp}\\child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, + "try{Set-Content -Path (Join-Path $env:TEMP 'child-wrote.txt') -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};", `try{Set-Content -Path '${escapeFile}' -Value ok -ErrorAction Stop;'ESCAPE-WRITE: OK (ESCAPE!)'}catch{'ESCAPE-WRITE: DENIED'};`, `try{Get-Content '${secretFile}' -ErrorAction Stop | Out-Null;'SECRET-READ: OK'}catch{'SECRET-READ: DENIED'};`, // Authenticated Users is absent from BOTH lists: the WMI namespace @@ -89,7 +97,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) - expect(result.stdout).toContain('LANGMODE: ConstrainedLanguage') + expect(result.stdout).toContain('LANGMODE: FullLanguage') expect(result.stdout).toContain('TARGET-WRITE: OK') expect(result.stdout).toContain('TEMP-WRITE: OK') expect(result.stdout).toContain('ESCAPE-WRITE: DENIED') @@ -99,13 +107,14 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(join(writableDir, 'child-wrote.txt'))).toBe(true) }, 30_000) - it('read-only: strict zero grants — no writes anywhere (not even NUL), reads and $null redirection fine, CIM unavailable', () => { + it('read-only: no write-SID grants — workspace/temp writes denied, reads and $null redirection fine, CIM unavailable', () => { const probe = [ "$ErrorActionPreference='SilentlyContinue';", '\'LANGMODE: \' + $ExecutionContext.SessionState.LanguageMode;', `try{Set-Content -Path '${writableDir}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TARGET-WRITE: OK'}catch{'TARGET-WRITE: DENIED'};`, `try{Set-Content -Path '${isolatedTemp}\\readonly-child-wrote.txt' -Value ok -ErrorAction Stop;'TEMP-WRITE: OK'}catch{'TEMP-WRITE: DENIED'};`, - // The NUL device is a securable object: strict zero grants deny it too. + // Set-Content NUL fails at the PowerShell/.NET layer even though the + // device DACL's Everyone rights remain an ambient backend boundary. 'try{Set-Content -Path \'NUL\' -Value ok -ErrorAction Stop;\'NUL-WRITE: OK\'}catch{\'NUL-WRITE: DENIED\'};', // PowerShell's $null redirection discards without opening NUL — must keep working. 'echo hi > $null;\'DOLLAR-NULL: OK\';', @@ -155,33 +164,37 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(renamedDir)).toBe(true) }, 30_000) - it('--write-sid: the runner trusts the caller-owned grants — private temp subdir via the TMP/TEMP env rewrite, no grants of its own', () => { - const writeSid = 'S-1-4-9000-99' + it('paired SIDs: the runner trusts caller-owned private-temp grants and materializes nothing itself', () => { + const seamWorkspace = join(scratchRoot, 'seam-workspace') + mkdirSync(seamWorkspace) + const writeSid = workspaceWriteSid(seamWorkspace) const privateTemp = join(isolatedTemp, 'private-subdir') mkdirSync(privateTemp) - const grant = AclWriteGrant.create(writeSid) + const privateTempSid = tempWriteSid(privateTemp) + const grant = AclWriteGrant.create(privateTempSid) grant.add(privateTemp) try { const probe = [ "$ErrorActionPreference='SilentlyContinue';", - `try{Set-Content -Path '${writableDir}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`, + `try{Set-Content -Path '${seamWorkspace}\\server-granted.txt' -Value ok -ErrorAction Stop;'WORKSPACE-WRITE: OK'}catch{'WORKSPACE-WRITE: DENIED'};`, `try{Set-Content -Path '${privateTemp}\\server-granted.txt' -Value ok -ErrorAction Stop;'PRIVATE-TEMP-WRITE: OK'}catch{'PRIVATE-TEMP-WRITE: DENIED'};`, "'TEMP-ENV: ' + $env:TEMP;", "'TMP-ENV: ' + $env:TMP", ].join('') const result = runRunner([ - '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--workspace', seamWorkspace, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--temp-write-sid', privateTempSid, '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', probe, ]) expect(result.status, `stderr: ${result.stderr}`).toBe(0) - // The runner granted nothing (only the caller's private-temp grant + // The runner granted nothing (only the caller's temp-SID grant // stands): the workspace write is denied, the private temp write lands, // and the child's TMP/TEMP point at the private subdirectory. expect(result.stdout).toContain('WORKSPACE-WRITE: DENIED') expect(result.stdout).toContain('PRIVATE-TEMP-WRITE: OK') expect(result.stdout).toContain(`TEMP-ENV: ${privateTemp}`) expect(result.stdout).toContain(`TMP-ENV: ${privateTemp}`) - expect(existsSync(join(writableDir, 'server-granted.txt'))).toBe(false) + expect(existsSync(join(seamWorkspace, 'server-granted.txt'))).toBe(false) expect(existsSync(join(privateTemp, 'server-granted.txt'))).toBe(true) } finally { grant.dispose() @@ -189,6 +202,97 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) + it('temp capabilities isolate sibling sessions that share one workspace SID', () => { + const writeSid = workspaceWriteSid(writableDir) + const tempA = join(isolatedTemp, 'session-a') + const tempB = join(isolatedTemp, 'session-b') + mkdirSync(tempA) + mkdirSync(tempB) + const sidA = tempWriteSid(tempA) + const sidB = tempWriteSid(tempB) + const workspaceGrant = AclWriteGrant.create(writeSid) + const grantA = AclWriteGrant.create(sidA) + const grantB = AclWriteGrant.create(sidB) + workspaceGrant.add(writableDir) + grantA.add(tempA) + grantB.add(tempB) + const sharedWorkspaceFile = join(writableDir, 'shared-between-sessions.txt') + const probe = [ + "const fs = require('node:fs');", + "const targets = [['OWN', process.argv[1]], ['SIBLING', process.argv[2]], ['WORKSPACE', process.argv[3]]];", + "if (process.argv[4]) targets.push(['SIBLING-EXISTING', process.argv[4]]);", + 'for (const [name, target] of targets) {', + "try { fs.writeFileSync(target, name); console.log(name + ': OK'); } catch { console.log(name + ': DENIED'); }", + '}', + ].join('') + try { + const resultA = runRunner([ + '--workspace', writableDir, '--temp', tempA, '--mode', 'workspace-write', + '--write-sid', writeSid, '--temp-write-sid', sidA, + '--', process.execPath, '-e', probe, join(tempA, 'a.txt'), join(tempB, 'a-escaped.txt'), sharedWorkspaceFile, + ]) + expect(resultA.status, `stderr: ${resultA.stderr}`).toBe(0) + expect(resultA.stdout).toContain('OWN: OK') + expect(resultA.stdout).toContain('SIBLING: DENIED') + expect(resultA.stdout).toContain('WORKSPACE: OK') + + const resultB = runRunner([ + '--workspace', writableDir, '--temp', tempB, '--mode', 'workspace-write', + '--write-sid', writeSid, '--temp-write-sid', sidB, + '--', process.execPath, '-e', probe, join(tempB, 'b.txt'), join(tempA, 'b-escaped.txt'), sharedWorkspaceFile, join(tempA, 'a.txt'), + ]) + expect(resultB.status, `stderr: ${resultB.stderr}`).toBe(0) + expect(resultB.stdout).toContain('OWN: OK') + expect(resultB.stdout).toContain('SIBLING: DENIED') + expect(resultB.stdout).toContain('SIBLING-EXISTING: DENIED') + expect(resultB.stdout).toContain('WORKSPACE: OK') + expect(existsSync(join(tempB, 'a-escaped.txt'))).toBe(false) + expect(existsSync(join(tempA, 'b-escaped.txt'))).toBe(false) + expect(readFileSync(join(tempA, 'a.txt'), 'utf8')).toBe('OWN') + } finally { + workspaceGrant.dispose() + grantA.dispose() + grantB.dispose() + rmSync(tempA, { recursive: true, force: true }) + rmSync(tempB, { recursive: true, force: true }) + } + }, 30_000) + + it('agentless workspace-write creates a fresh private temp per call and removes it on exit', () => { + const captureA = join(writableDir, 'agentless-temp-a.txt') + const captureB = join(writableDir, 'agentless-temp-b.txt') + for (const capture of [captureA, captureB]) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], process.env.TEMP)", capture, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + } + const tempA = readFileSync(captureA, 'utf8') + const tempB = readFileSync(captureB, 'utf8') + expect(tempA).not.toBe(tempB) + expect(tempA.startsWith(isolatedTemp)).toBe(true) + expect(tempB.startsWith(isolatedTemp)).toBe(true) + expect(existsSync(tempA)).toBe(false) + expect(existsSync(tempB)).toBe(false) + }, 30_000) + + it('agentless workspace-write rejects a temp root inside the workspace before spawning', () => { + const overlapWorkspace = join(scratchRoot, 'overlap-workspace') + const nestedTempRoot = join(overlapWorkspace, 'temp') + const marker = join(overlapWorkspace, 'command-ran.txt') + mkdirSync(overlapWorkspace) + mkdirSync(nestedTempRoot) + + const result = runRunner([ + '--workspace', overlapWorkspace, '--temp', nestedTempRoot, '--mode', 'workspace-write', + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'ran')", marker, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(127) + expect(result.stderr).toContain('windows-acl-run: Windows ACL temp root must be outside the workspace') + expect(existsSync(marker)).toBe(false) + }, 15_000) + it('confined children spawn grandchildren with inherited stdio; piped capture stays denied (named-pipe default SD template)', () => { // Two-layer pin of the grandchild-spawn boundary: // - the token default DACL carries a restricting-SID ACE (set in init), @@ -226,11 +330,14 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { // The reported defect: a session that materialized its grant in // workspace-write keeps the ACE standing for the server lifetime. After // switching to read-only, the restricted token's read-only list must carry NO - // orphan SID — the standing ACE stays but the pass-2 check cannot use + // capability SID — the standing ACE stays but the pass-2 check cannot use // it, so the workspace write is denied (previously it LEAKED). The // switch back reuses the SAME standing ACE: the re-upgrade write lands // without any re-grant. - const writeSid = 'S-1-4-9001-7' + const writeSid = workspaceWriteSid(writableDir) + const privateTemp = join(isolatedTemp, 'mode-switch-temp') + mkdirSync(privateTemp) + const privateTempSid = tempWriteSid(privateTemp) const grant = AclWriteGrant.create(writeSid) grant.add(writableDir) try { @@ -239,7 +346,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { `try{Set-Content -Path '${writableDir}\\downgraded.txt' -Value ok -ErrorAction Stop;'DOWNGRADE-WRITE: OK (LEAK!)'}catch{'DOWNGRADE-WRITE: DENIED'}`, ].join('') const downgraded = runRunner([ - '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--write-sid', writeSid, + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'read-only', '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', downgradeProbe, ]) expect(downgraded.status, `stderr: ${downgraded.stderr}`).toBe(0) @@ -251,7 +358,8 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { `try{Set-Content -Path '${writableDir}\\reupgraded.txt' -Value ok -ErrorAction Stop;'REUPGRADE-WRITE: OK'}catch{'REUPGRADE-WRITE: DENIED'}`, ].join('') const reupgraded = runRunner([ - '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--workspace', writableDir, '--temp', privateTemp, '--mode', 'workspace-write', '--write-sid', writeSid, + '--temp-write-sid', privateTempSid, '--', 'pwsh', '/NoLogo', '/NonInteractive', '/NoProfile', '/Command', reupgradeProbe, ]) expect(reupgraded.status, `stderr: ${reupgraded.stderr}`).toBe(0) @@ -259,6 +367,7 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { expect(existsSync(join(writableDir, 'reupgraded.txt'))).toBe(true) } finally { grant.dispose() + rmSync(privateTemp, { recursive: true, force: true }) } }, 30_000) @@ -286,9 +395,67 @@ describe.skipIf(!isWin32 || !pwshAvailable())('windows-acl runner', () => { } }, 30_000) + it('partial boundary: an external Everyone-Modify directory stays writable under BOTH modes', () => { + // Everyone is a required keep-alive restricting SID: without it early DLL + // initialization and CNG fail. A normal DACL that grants Everyone Modify + // therefore also clears the WRITE_RESTRICTED pass-2 check. Pin this + // unavoidable gap beside the provider's `partial` enforcement report. + for (const mode of ['read-only', 'workspace-write'] as const) { + const target = join(worldWritableDir, `${mode}.txt`) + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', mode, + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'written')", target, + ]) + expect(result.status, `mode: ${mode}\nstderr: ${result.stderr}`).toBe(0) + expect(existsSync(target), `mode: ${mode}`).toBe(true) + } + }, 30_000) + + it('partial boundary: a workspace hard link lets the grant reach an external file object', () => { + // NTFS ACLs belong to the file object, not one pathname. Propagating the + // workspace write-SID ACE through an existing hard-link alias therefore + // grants the external alias too. pnpm workspaces commonly contain hard + // links, so rejecting every multiply-linked file is not a viable profile. + const hardlinkWorkspace = join(scratchRoot, 'hardlink-workspace') + const hardlinkTemp = join(scratchRoot, 'hardlink-temp') + const externalFile = join(scratchRoot, 'hardlink-target.txt') + const workspaceLink = join(hardlinkWorkspace, 'hardlink-alias.txt') + mkdirSync(hardlinkWorkspace) + mkdirSync(hardlinkTemp) + writeFileSync(externalFile, 'original') + linkSync(externalFile, workspaceLink) + const result = runRunner([ + // This workspace has not been granted before the alias exists: the first + // recursive materialization reaches the shared file security descriptor. + '--workspace', hardlinkWorkspace, '--temp', hardlinkTemp, '--mode', 'workspace-write', + '--', process.execPath, '-e', "require('node:fs').writeFileSync(process.argv[1], 'mutated')", workspaceLink, + ]) + expect(result.status, `stderr: ${result.stderr}`).toBe(0) + expect(readFileSync(externalFile, 'utf8')).toBe('mutated') + }, 30_000) + it('runner-side failure: signature on stderr and exit 127, the command never runs', () => { const result = runRunner(['--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write']) expect(result.status).toBe(127) expect(result.stderr).toContain('windows-acl-run: ') }, 15_000) + + it('runner-side failure: seam-managed SID flags must be paired and match their owning paths', () => { + const writeSid = workspaceWriteSid(writableDir) + const tempSid = tempWriteSid(isolatedTemp) + const cases = [ + ['--write-sid', writeSid], + ['--write-sid', 'S-1-4-1-2', '--temp-write-sid', tempSid], + ['--write-sid', writeSid, '--temp-write-sid', 'S-1-4-1-2-1'], + ] + for (const args of cases) { + const result = runRunner([ + '--workspace', writableDir, '--temp', isolatedTemp, '--mode', 'workspace-write', + ...args, + '--', process.execPath, '-e', 'process.exit(99)', + ]) + expect(result.status, `args: ${args.join(' ')}\nstderr: ${result.stderr}`).toBe(127) + expect(result.stderr).toContain('windows-acl-run: ') + } + }, 15_000) }) diff --git a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts index 046a87664a..b3559ee781 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/token-failure-paths.spec.ts @@ -383,7 +383,7 @@ describe('createRestrictedToken failure paths', () => { }) const api = { createRestrictedToken: create } as unknown as Win32Bindings const logon = allocBytes(12) - expect(createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only')).toBe(9n) + expect(createRestrictedToken(api, 1n as NativePtr, logon, [], { world: 2n as NativePtr }, 'read-only')).toBe(9n) }) it('builds the workspace-write restricting list with the write SID', () => { @@ -397,7 +397,7 @@ describe('createRestrictedToken failure paths', () => { }) const api = { createRestrictedToken: create } as unknown as Win32Bindings const logon = allocBytes(12) - expect(createRestrictedToken(api, 1n as NativePtr, logon, 3n as NativePtr, { world: 2n as NativePtr }, 'workspace-write')).toBe(9n) + expect(createRestrictedToken(api, 1n as NativePtr, logon, [3n as NativePtr], { world: 2n as NativePtr }, 'workspace-write')).toBe(9n) }) it('reports when CreateRestrictedToken fails', () => { @@ -409,7 +409,7 @@ describe('createRestrictedToken failure paths', () => { const logon = allocBytes(12) let caught: unknown try { - createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only') + createRestrictedToken(api, 1n as NativePtr, logon, [], { world: 2n as NativePtr }, 'read-only') } catch (error) { caught = error } @@ -426,7 +426,7 @@ describe('createRestrictedToken failure paths', () => { const logon = allocBytes(12) let caught: unknown try { - createRestrictedToken(api, 1n as NativePtr, logon, undefined, { world: 2n as NativePtr }, 'read-only') + createRestrictedToken(api, 1n as NativePtr, logon, [], { world: 2n as NativePtr }, 'read-only') } catch (error) { caught = error } diff --git a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts index 4d24c6f8fb..7fdef07d53 100644 --- a/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts +++ b/packages/sandbox/sandbox-windows-acl/tests/workspace-sid.spec.ts @@ -1,7 +1,7 @@ /** * workspaceWriteSid tests: the per-workspace write identity is deterministic * (the same canonical path always derives the same SID — the property the - * cross-session grant reuse rests on), orphan-shaped, distinct across + * cross-session grant reuse rests on), capability-shaped, distinct across * workspaces, and byte-sensitive (the canonical path is the caller's * contract; an alias spelling derives a second identity, self-healing at * the cost of one extra tree propagation). @@ -9,10 +9,10 @@ import { describe, expect, it } from 'vitest' -import { workspaceWriteSid } from '../src/index.ts' +import { tempWriteSid, workspaceWriteSid } from '../src/index.ts' describe('workspaceWriteSid', () => { - it('derives a stable orphan-shaped SID per workspace path', () => { + it('derives a stable capability-shaped SID per workspace path', () => { const first = workspaceWriteSid('C:\\Users\\agent\\repo') const second = workspaceWriteSid('C:\\Users\\agent\\repo') expect(first).toBe(second) @@ -28,3 +28,16 @@ describe('workspaceWriteSid', () => { expect(workspaceWriteSid('C:\\Repo\\')).not.toBe(workspaceWriteSid('C:\\Repo')) }) }) + +describe('tempWriteSid', () => { + it('derives a stable domain-separated SID per private temp path', () => { + const temp = tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123') + expect(temp).toBe(tempWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')) + expect(temp).toMatch(/^S-1-4-\d+-\d+-1$/u) + expect(temp).not.toBe(workspaceWriteSid('C:\\Users\\agent\\AppData\\Local\\Temp\\dsh-abc123')) + }) + + it('derives distinct capabilities for distinct private temp paths', () => { + expect(tempWriteSid('C:\\Temp\\dsh-a')).not.toBe(tempWriteSid('C:\\Temp\\dsh-b')) + }) +}) diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 7c91afe76b..b25c8b74a0 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-sandbox", "description": "Abstract process-sandbox seam (ctx.sandbox) for the DeepSeek Harness: same-world confinement vocabulary and the SandboxProvider contract", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/sandbox/sandbox" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/sandbox/sandbox/src/index.ts b/packages/sandbox/sandbox/src/index.ts index 5fa3cc991b..2d9ee5b91b 100644 --- a/packages/sandbox/sandbox/src/index.ts +++ b/packages/sandbox/sandbox/src/index.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-sandbox */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' @@ -43,10 +43,10 @@ export interface SandboxExecutionPolicy { workspaceRoot: string /** * Opaque identity of the calling session (the branded `dsh-session` - * SessionId). Backends key per-session state off it (e.g. the windows-acl - * per-session private temp subdirectory — the write grant itself is - * per-workspace, derived from the workspace root); absent for agentless - * calls, which fall back to per-call backend state. + * SessionId). Backends key per-session state off it (e.g. windows-acl gives + * each live session/workspace pair a random private temp directory and SID, + * while the workspace SID and standing grant remain per-workspace); absent + * for agentless calls, which fall back to per-call backend state. */ sessionId?: SessionId } @@ -143,7 +143,7 @@ export class SandboxUnavailableError extends HarnessError { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sandbox: SandboxProvider } @@ -156,7 +156,7 @@ declare module 'cordis' { * skipped for a sole candidate, whose own refusal remains the fail-closed end. */ export abstract class SandboxProvider extends Service { - /* v8 ignore next -- Windows has no sandbox backend to instantiate this service. */ + /* v8 ignore next -- abstract service construction is covered through concrete provider packages. */ constructor(ctx: Context) { super(ctx, 'sandbox') } diff --git a/packages/sandbox/sandbox/src/invariant.ts b/packages/sandbox/sandbox/src/invariant.ts index 7ee5be733f..4d7f6dcdf0 100644 --- a/packages/sandbox/sandbox/src/invariant.ts +++ b/packages/sandbox/sandbox/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sandbox' diff --git a/packages/scaffold/client/package.json b/packages/scaffold/client/package.json index 3dbb236ab3..796a32d5c0 100644 --- a/packages/scaffold/client/package.json +++ b/packages/scaffold/client/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-sdk-client", "description": "TypeScript client SDK for driving a DeepSeek Harness runtime subprocess over stdio JSON-RPC: the DeepSeekHarness high-level turns API and the lower-level HarnessClient", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/scaffold/client" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -24,17 +31,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sdk-protocol": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-sdk-protocol": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/scaffold/client/src/invariant.ts b/packages/scaffold/client/src/invariant.ts index db40e4e005..b93254ee9f 100644 --- a/packages/scaffold/client/src/invariant.ts +++ b/packages/scaffold/client/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-client' diff --git a/packages/scaffold/client/tests/fake-runtime.ts b/packages/scaffold/client/tests/fake-runtime.ts index 85d5253765..4fb2b6017a 100644 --- a/packages/scaffold/client/tests/fake-runtime.ts +++ b/packages/scaffold/client/tests/fake-runtime.ts @@ -26,6 +26,8 @@ * array; `FAKE_MESSAGE_WITHOUT_DATA`: assistant/message with no data * member; `FAKE_MALFORMED_REASON`: `session.finished` reason is a bare * string (wire-validation probes). + * - `FAKE_EMPTY_MESSAGE`: the turn streams a text chunk, then records an empty + * assistant/message for a usage-only max-tokens step. * - `FAKE_HANG_INIT`: never answer `initialize` (mid-handshake cancel probe). * - `FAKE_INIT_READY` + `FAKE_INIT_GO`: touch the READY file when `initialize` * arrives, then poll for the GO file before answering (deterministic @@ -117,7 +119,9 @@ function runTurn(sessionId: string): void { message: { id: `fake-assistant-${seq}`, role: 'assistant', - content: [{ type: 'text', text }], + // Model the usage-only message recorded after a max-tokens step that + // assembled no output blocks. + content: env.FAKE_EMPTY_MESSAGE !== undefined ? [] : [{ type: 'text', text }], source: { kind: 'model', provider: 'fake', model: 'fake' }, }, }) diff --git a/packages/scaffold/create-sdk/package.json b/packages/scaffold/create-sdk/package.json index 262d375563..33cf2f2f15 100644 --- a/packages/scaffold/create-sdk/package.json +++ b/packages/scaffold/create-sdk/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/create-sdk", "description": "Create a DeepSeek Harness SDK project with npm create @deepseek-ai/sdk", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/scaffold/create-sdk" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -32,11 +39,11 @@ "commander": "^15.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/scaffold/create-sdk/src/invariant.ts b/packages/scaffold/create-sdk/src/invariant.ts index 368b46fc70..a4de619111 100644 --- a/packages/scaffold/create-sdk/src/invariant.ts +++ b/packages/scaffold/create-sdk/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/create-sdk' diff --git a/packages/scaffold/create-sdk/tests/create.spec.ts b/packages/scaffold/create-sdk/tests/create.spec.ts index 6c2995ec7d..060f76b11d 100644 --- a/packages/scaffold/create-sdk/tests/create.spec.ts +++ b/packages/scaffold/create-sdk/tests/create.spec.ts @@ -461,7 +461,11 @@ describe('CreateWizard and scaffolder', () => { }) it('reads the release batch from the initializer package', async () => { - await expect(readCreateSdkVersion()).resolves.toBe('0.0.1') + // The version tracks the release, including a prerelease such as 0.0.1-rc.1, + // so the expectation comes from the manifest rather than a literal. + const manifest = JSON.parse(await readFile(new URL('../package.json', import.meta.url), 'utf8')) as { version: string } + + await expect(readCreateSdkVersion()).resolves.toBe(manifest.version) }) }) diff --git a/packages/scaffold/create-sdk/tests/link-workspace.e2e.ts b/packages/scaffold/create-sdk/tests/link-workspace.e2e.ts index b978c8217e..0e995202fd 100644 --- a/packages/scaffold/create-sdk/tests/link-workspace.e2e.ts +++ b/packages/scaffold/create-sdk/tests/link-workspace.e2e.ts @@ -73,7 +73,7 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', () }) await writeFile(join(root, 'plugins/probe/src/index.ts'), ` import { writeFileSync } from 'node:fs' - import type { Context } from 'cordis' + import type { Context } from '@deepseek-ai/cordis' export const name = 'probe' export function apply(_ctx: Context): void { writeFileSync(new URL('../../../plugin-loaded', import.meta.url), 'loaded\\n') @@ -119,7 +119,7 @@ describe.skipIf(!existsSync(builtScripts))('live-linked generated projects', () const manifest = JSON.parse(await readFile(join(root, 'package.json'), 'utf8')) as { dependencies: Record } - expect(manifest.dependencies.cordis).toMatch(name === 'npm' ? /^file:/ : name === 'pnpm' ? /^link:/ : /^portal:/) + expect(manifest.dependencies['@deepseek-ai/cordis']).toMatch(name === 'npm' ? /^file:/ : name === 'pnpm' ? /^link:/ : /^portal:/) expect(manifest.dependencies).not.toHaveProperty('node-addon-require-builtin') }, 180_000) } diff --git a/packages/scaffold/helper/package.json b/packages/scaffold/helper/package.json index bed70dbbda..74df5f76a8 100644 --- a/packages/scaffold/helper/package.json +++ b/packages/scaffold/helper/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-helper", "description": "Domain model and infrastructure for creating and editing DeepSeek Harness SDK projects", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/scaffold/helper" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -31,10 +38,10 @@ "yaml": "^2.9.0" }, "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -47,6 +54,6 @@ "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/scaffold/helper/src/features/builtin/index.ts b/packages/scaffold/helper/src/features/builtin/index.ts index abdcc35dd5..38246e6cf2 100644 --- a/packages/scaffold/helper/src/features/builtin/index.ts +++ b/packages/scaffold/helper/src/features/builtin/index.ts @@ -101,7 +101,7 @@ config: id: 'default', label: 'Cordis HMR', default: true, - resources: [{ kind: 'npm-cordis-config-entry', id: 'hmr', package: '@cordisjs/plugin-hmr' }], + resources: [{ kind: 'npm-cordis-config-entry', id: 'hmr', package: '@deepseek-ai/cordis-plugin-hmr' }], }], }, { diff --git a/packages/scaffold/helper/src/features/builtin/spine.ts b/packages/scaffold/helper/src/features/builtin/spine.ts index 3e1acb98fb..26f0d03232 100644 --- a/packages/scaffold/helper/src/features/builtin/spine.ts +++ b/packages/scaffold/helper/src/features/builtin/spine.ts @@ -26,7 +26,7 @@ class SpineOption extends FeatureOption { override contribution(_profile: ProjectProfile): ProjectContribution { return new ProjectContribution([ - ...npmCordisConfigEntry(ID, { id: 'timer', name: '@cordisjs/plugin-timer' }), + ...npmCordisConfigEntry(ID, { id: 'timer', name: '@deepseek-ai/cordis-plugin-timer' }), ...npmCordisConfigEntry(ID, { id: 'llm', name: '@deepseek-ai/dsh-llm' }), ...npmCordisConfigEntry(ID, { id: 'session', name: '@deepseek-ai/dsh-session' }), ...npmCordisConfigEntry(ID, { diff --git a/packages/scaffold/helper/src/invariant.ts b/packages/scaffold/helper/src/invariant.ts index 9185ac8867..dfebbb2006 100644 --- a/packages/scaffold/helper/src/invariant.ts +++ b/packages/scaffold/helper/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-helper' diff --git a/packages/scaffold/helper/src/package-managers/link-workspace.ts b/packages/scaffold/helper/src/package-managers/link-workspace.ts index 1a6b518fa9..4d185ce4d1 100644 --- a/packages/scaffold/helper/src/package-managers/link-workspace.ts +++ b/packages/scaffold/helper/src/package-managers/link-workspace.ts @@ -73,7 +73,7 @@ export class LinkWorkspace { if (packages.has(manifest.name)) throw new Error(`duplicate linked package name: ${manifest.name}`) packages.set(manifest.name, { directory, manifest }) } - if (!packages.has('cordis') || !packages.has('@deepseek-ai/dsh-scripts')) { + if (!packages.has('@deepseek-ai/cordis') || !packages.has('@deepseek-ai/dsh-scripts')) { throw new Error(`not a DeepSeek Harness repository root: ${absolute}`) } return new LinkWorkspace(absolute, packages) @@ -134,4 +134,37 @@ export class LinkWorkspace { ? resolve(dirname(directory), directory.split(sep).at(-1) as string) : undefined } + + /** + * Rewrite one nested generated manifest's local dependencies to live-link specs. + * + * A generated workspace member resolves its own dependencies, so every local + * name it declares must point into this repository as well: none of them — + * the harness packages or the rescoped framework — exists on a public + * registry, so a semver spec there fails the install outright. + * `peerDependencies` keeps its range because a peer states what the consumer + * must supply, and package managers reject a link spec in that section. + * @param projectRoot - Absolute root of the generated project. + * @param manifestPath - The nested manifest's project-relative POSIX path. + * @param text - The nested manifest's complete current text. + * @param manager - Package manager whose link-spec form applies. + * @returns The manifest text with every resolved local dependency relinked. + */ + relinkNestedManifest(projectRoot: string, manifestPath: string, text: string, manager: PackageManager): string { + const manifest = JSON.parse(text) as Record + const manifestDirectory = resolve(canonicalPath(projectRoot), dirname(manifestPath)) + let changed = false + for (const section of ['dependencies', 'devDependencies', 'optionalDependencies']) { + const dependencies = manifest[section] + if (typeof dependencies !== 'object' || dependencies === null) continue + for (const [name] of Object.entries(dependencies as Record)) { + const pkg = this.packages.get(name) + if (!pkg) continue + const relativePath = posixPath(relative(manifestDirectory, realpathSync(pkg.directory))) + ;(dependencies as Record)[name] = manager.linkSpec(relativePath) + changed = true + } + } + return changed ? `${JSON.stringify(manifest, null, 2)}\n` : text + } } diff --git a/packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts b/packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts index 616d0cedd8..da978e845c 100644 --- a/packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts +++ b/packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts @@ -83,7 +83,7 @@ export class LocalPluginBlueprint { documents(projectName: string, releaseVersion: string): TextProjectFile[] { const name = this.packageName(projectName) const toolName = this.name.replaceAll('-', '_') - const cordisSpec = resolveNpmDependency('cordis', 'devDependencies', releaseVersion).spec + const cordisSpec = resolveNpmDependency('@deepseek-ai/cordis', 'devDependencies', releaseVersion).spec const manifest = { name, version: '0.0.0', @@ -94,10 +94,10 @@ export class LocalPluginBlueprint { exports: { '.': { types: './lib/index.d.ts', default: './lib/index.js' } }, peerDependencies: { ...this.kind === 'tool' ? { '@deepseek-ai/dsh-tools': `^${releaseVersion}` } : {}, - cordis: cordisSpec, + '@deepseek-ai/cordis': cordisSpec, }, devDependencies: { - cordis: cordisSpec, + '@deepseek-ai/cordis': cordisSpec, }, } const tsconfig = { diff --git a/packages/scaffold/helper/src/project/npm-dependency-policy.ts b/packages/scaffold/helper/src/project/npm-dependency-policy.ts index 727bd6648a..a876973a29 100644 --- a/packages/scaffold/helper/src/project/npm-dependency-policy.ts +++ b/packages/scaffold/helper/src/project/npm-dependency-policy.ts @@ -19,17 +19,17 @@ export interface BaselineNpmDependencies { } const EXTERNAL_NPM_DEPENDENCY_SPECS: Readonly> = { - '@cordisjs/plugin-hmr': '^1.0.15', - '@cordisjs/plugin-timer': '^1.1.2', + '@deepseek-ai/cordis-plugin-hmr': '^1.0.15', + '@deepseek-ai/cordis-plugin-timer': '^1.1.2', '@types/node': '^22.20.0', - cordis: '^4.0.0-rc.7', + '@deepseek-ai/cordis': '^4.0.0-rc.7', tsdown: '0.22.2', tsx: '^4.22.4', typescript: '^6.0.3', } const BASELINE_NPM_DEPENDENCY_NAMES: Readonly> = { - dependencies: ['@deepseek-ai/dsh-scripts', 'cordis'], + dependencies: ['@deepseek-ai/dsh-scripts', '@deepseek-ai/cordis'], devDependencies: ['@types/node', 'tsdown', 'tsx', 'typescript'], } diff --git a/packages/scaffold/helper/src/project/project-edit-session.ts b/packages/scaffold/helper/src/project/project-edit-session.ts index 4c3d39c07c..3d86050017 100644 --- a/packages/scaffold/helper/src/project/project-edit-session.ts +++ b/packages/scaffold/helper/src/project/project-edit-session.ts @@ -17,7 +17,7 @@ import type { ProjectResource } from '../features/resources.ts' import { CordisYamlFile, type CordisConfigEntry } from '../documents/cordis-yaml-file.ts' import { EnvFile } from '../documents/env-file.ts' import { PackageJsonFile, type PackageManifest } from '../documents/package-json-file.ts' -import { ProjectFile } from '../documents/project-file.ts' +import { ProjectFile, TextProjectFile } from '../documents/project-file.ts' import { TsConfigFile } from '../documents/tsconfig-file.ts' import { featureId, type FeatureId, type ResourceKey } from '../ids.ts' import { LinkWorkspace } from '../package-managers/link-workspace.ts' @@ -288,6 +288,18 @@ export class ProjectEditSession implements FeatureProjectView { this.profile.packageManager, [...this.documents.values()], ) + // Generated workspace members resolve their own dependencies, so the root + // manifest's links are not enough: relink every nested manifest as well. + for (const [path, document] of this.documents) { + if (path === 'package.json' || !path.endsWith('/package.json')) continue + const relinked = workspace.relinkNestedManifest( + this.source.root, + path, + document.serialize(), + this.profile.packageManager, + ) + this.documents.set(path, new TextProjectFile(path, relinked, document.originalText)) + } } this.validateFinalState() const changes = this.changes() diff --git a/packages/scaffold/helper/src/templates/assets/local-plugin.ts.tpl b/packages/scaffold/helper/src/templates/assets/local-plugin.ts.tpl index 2f15bdef91..76b12769bb 100644 --- a/packages/scaffold/helper/src/templates/assets/local-plugin.ts.tpl +++ b/packages/scaffold/helper/src/templates/assets/local-plugin.ts.tpl @@ -1,5 +1,5 @@ /** Local Cordis plugin. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' export const name = '{{pluginName}}' diff --git a/packages/scaffold/helper/src/templates/assets/local-tool.ts.tpl b/packages/scaffold/helper/src/templates/assets/local-tool.ts.tpl index 4333d41c70..a48a73375c 100644 --- a/packages/scaffold/helper/src/templates/assets/local-tool.ts.tpl +++ b/packages/scaffold/helper/src/templates/assets/local-tool.ts.tpl @@ -1,5 +1,5 @@ /** Project-local model-facing tool. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' export const name = '{{pluginName}}' diff --git a/packages/scaffold/helper/tests/documents.spec.ts b/packages/scaffold/helper/tests/documents.spec.ts index e3ffe18b77..12e97dc6c0 100644 --- a/packages/scaffold/helper/tests/documents.spec.ts +++ b/packages/scaffold/helper/tests/documents.spec.ts @@ -279,10 +279,10 @@ describe('package manager strategies', () => { expect(inferPackageManagerName(undefined, 'unknown/1')).toBeUndefined() expect(inferPackageManagerName('yarn', undefined)).toBe('yarn') expect(() => createPackageManager('npm', 'invalid')).toThrow('invalid package manager version') - expect(resolveNpmDependency('cordis', 'devDependencies', '0.0.1')).toEqual({ + expect(resolveNpmDependency('@deepseek-ai/cordis', 'devDependencies', '0.0.1')).toEqual({ section: 'devDependencies', spec: '^4.0.0-rc.7', }) - expect(resolveNpmDependency('@cordisjs/plugin-hmr', 'dependencies', '0.0.1').spec).toBe('^1.0.15') + expect(resolveNpmDependency('@deepseek-ai/cordis-plugin-hmr', 'dependencies', '0.0.1').spec).toBe('^1.0.15') expect(resolveNpmDependency('tsdown', 'devDependencies', '0.0.1').spec).toBe('0.22.2') expect(resolveNpmDependency('@deepseek-ai/dsh-tools', 'dependencies', '1.2.3').spec).toBe('^1.2.3') expect(() => resolveNpmDependency('unknown', 'dependencies', '0.0.1')).toThrow('no generated-project') @@ -353,24 +353,45 @@ describe('package manager strategies', () => { await mkdir(join(root, 'vendor', 'cordis'), { recursive: true }) await mkdir(join(root, 'packages', 'sdk', 'scripts'), { recursive: true }) await mkdir(join(root, 'packages', 'sdk', 'helper'), { recursive: true }) - await writeFile(join(root, 'vendor', 'cordis', 'package.json'), JSON.stringify({ name: 'cordis' })) + await writeFile(join(root, 'vendor', 'cordis', 'package.json'), JSON.stringify({ name: '@deepseek-ai/cordis' })) await writeFile(join(root, 'packages', 'sdk', 'helper', 'package.json'), JSON.stringify({ name: '@deepseek-ai/dsh-helper' })) await writeFile(join(root, 'packages', 'sdk', 'scripts', 'package.json'), JSON.stringify({ - name: '@deepseek-ai/dsh-scripts', dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1' }, peerDependencies: { cordis: '^4' }, + name: '@deepseek-ai/dsh-scripts', dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1' }, peerDependencies: { '@deepseek-ai/cordis': '^4' }, })) const workspace = await LinkWorkspace.open(root) expect(workspace.closure(['@deepseek-ai/dsh-scripts'])).toEqual([ - '@deepseek-ai/dsh-helper', '@deepseek-ai/dsh-scripts', 'cordis', + '@deepseek-ai/cordis', '@deepseek-ai/dsh-helper', '@deepseek-ai/dsh-scripts', ]) const manifest = PackageJsonFile.create('{"name":"consumer","description":"test"}') manifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1') const pnpmWorkspace = PnpmWorkspaceFile.create() workspace.apply(join(root, 'consumer'), manifest, new PnpmPackageManager('10.0.0'), [pnpmWorkspace]) - expect(manifest.npmDependency('cordis')?.spec).toMatch(/^link:/) + expect(manifest.npmDependency('@deepseek-ai/cordis')?.spec).toMatch(/^link:/) expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false') - expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis')) - expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis') + expect(workspace.packageDirectory('@deepseek-ai/cordis')).toBe(join(root, 'vendor', 'cordis')) + expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('@deepseek-ai/cordis') expect(workspace.packageDirectory('missing')).toBeUndefined() + // A generated workspace member resolves its own dependencies: every local name it + // declares relinks, while a peer keeps the range package managers require there. + const nested = workspace.relinkNestedManifest(join(root, 'consumer'), 'plugins/probe/package.json', `${JSON.stringify({ + name: 'probe', + dependencies: { '@deepseek-ai/dsh-helper': '^0.0.1', 'left-pad': '^1' }, + peerDependencies: { '@deepseek-ai/dsh-scripts': '^0.0.1' }, + devDependencies: { '@deepseek-ai/dsh-scripts': '^0.0.1' }, + }, null, 2)}\n`, new PnpmPackageManager('10.0.0')) + const nestedManifest = JSON.parse(nested) as { + dependencies: Record + peerDependencies: Record + devDependencies: Record + } + expect(nestedManifest.dependencies['@deepseek-ai/dsh-helper']).toMatch(/^link:\.\.\/\.\.\//) + expect(nestedManifest.dependencies['left-pad']).toBe('^1') + expect(nestedManifest.devDependencies['@deepseek-ai/dsh-scripts']).toMatch(/^link:\.\.\/\.\.\//) + expect(nestedManifest.peerDependencies['@deepseek-ai/dsh-scripts']).toBe('^0.0.1') + // Nothing local to relink, and a non-object section, leave the text byte-identical. + const untouched = `${JSON.stringify({ name: 'probe', dependencies: { 'left-pad': '^1' }, devDependencies: null }, null, 2)}\n` + expect(workspace.relinkNestedManifest(join(root, 'consumer'), 'plugins/probe/package.json', untouched, new PnpmPackageManager('10.0.0'))) + .toBe(untouched) const yarnManifest = PackageJsonFile.create('{"name":"consumer"}') yarnManifest.setNpmDependency('dependencies', '@deepseek-ai/dsh-scripts', '^0.0.1') workspace.apply(join(root, 'consumer-yarn'), yarnManifest, new YarnPackageManager('4.0.0'), []) diff --git a/packages/scaffold/helper/tests/project.spec.ts b/packages/scaffold/helper/tests/project.spec.ts index 41acf530ba..b803e658d7 100644 --- a/packages/scaffold/helper/tests/project.spec.ts +++ b/packages/scaffold/helper/tests/project.spec.ts @@ -187,12 +187,12 @@ describe('SdkProject and ProjectEditSession', () => { expect(project.cordis.entry('scope-invariant')?.name).toBe('@deepseek-ai/dsh-scope/invariant') expect(project.cordis.entry('agent-loop-invariant')?.name).toBe('@deepseek-ai/dsh-agent-loop/invariant') expect(project.cordis.entry('system-prompt')?.config?.persona).toContain('{{cwd}}') - expect(project.packageManifest().dependencies?.['@cordisjs/plugin-timer']).toBe('^1.1.2') - expect(project.packageManifest().dependencies?.['@cordisjs/plugin-hmr']).toBe('^1.0.15') + expect(project.packageManifest().dependencies?.['@deepseek-ai/cordis-plugin-timer']).toBe('^1.1.2') + expect(project.packageManifest().dependencies?.['@deepseek-ai/cordis-plugin-hmr']).toBe('^1.0.15') expect(project.packageManifest().dependencies?.['@deepseek-ai/dsh-scope']).toBe('^0.0.1') expect(project.packageManifest().dependencies).not.toHaveProperty('@deepseek-ai/dsh-scope/invariant') expect(project.packageManifest().dependencies).not.toHaveProperty('node-addon-require-builtin') - expect(project.cordis.entry('hmr')).toMatchObject({ name: '@cordisjs/plugin-hmr' }) + expect(project.cordis.entry('hmr')).toMatchObject({ name: '@deepseek-ai/cordis-plugin-hmr' }) expect(project.cordis.entry('llm-deepseek')).not.toHaveProperty('config.apiKey') expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('baseURL') expect(project.cordis.entry('llm-deepseek')?.config).not.toHaveProperty('models') @@ -502,7 +502,7 @@ describe('SdkProject and ProjectEditSession', () => { internals.applyResource(transient, undefined) internals.removeResource(transient) internals.replaceContribution( - new ProjectContribution([{ kind: 'npm-dependency', key: resourceKey('shared'), name: 'cordis', section: 'dependencies' }]), + new ProjectContribution([{ kind: 'npm-dependency', key: resourceKey('shared'), name: '@deepseek-ai/cordis', section: 'dependencies' }]), new ProjectContribution([{ kind: 'cordis-config-entry', key: resourceKey('shared'), entry: { id: 'new', name: 'new' }, ownedConfigKeys: [], }]), @@ -698,14 +698,24 @@ describe('SdkProject and ProjectEditSession', () => { it('does not mistake a linked NPM dependency closure for an installed feature', async () => { const root = await mkdtemp(join(tmpdir(), 'dsh-link-closure-inspection-')) temporary.push(root) - const base = request([selection('hooks', ['claude'])]) + const base = request([selection('hooks', ['claude'])], [new LocalPluginBlueprint('probe', 'plugin')]) const creation: ProjectCreationRequest = { ...base, linkWorkspaceRoot: repoRoot } const project = SdkProject.create(root, creation) const registry = createBuiltinRegistry(project.profile) const edit = project.edit(registry) for (const item of creation.features) edit.installFeature(registry.get(item.id), item) + for (const blueprint of creation.localPlugins) edit.addPlugin(blueprint) const committed = (await edit.commit()).project expect(committed.packageManifest().dependencies?.['@deepseek-ai/dsh-subagent']).toMatch(/^file:/) + // A generated workspace member resolves its own dependencies, so its manifest links too. + const plugin = JSON.parse(await readFile(join(root, 'plugins/probe/package.json'), 'utf8')) as { + devDependencies?: Record + peerDependencies?: Record + } + // Asserted by shape, not by the framework's name: what matters is that the + // resolved section links into this repository while the peer keeps its range. + expect(Object.values(plugin.devDependencies ?? {}).every(spec => spec.startsWith('file:'))).toBe(true) + expect(Object.values(plugin.peerDependencies ?? {}).some(spec => spec.startsWith('^'))).toBe(true) expect(createBuiltinRegistry(committed.profile).get(featureId('subagent')).inspect(committed).state).toBe('absent') }) diff --git a/packages/scaffold/protocol/README.i18n.yaml b/packages/scaffold/protocol/README.i18n.yaml index f038434410..541155d37c 100644 --- a/packages/scaffold/protocol/README.i18n.yaml +++ b/packages/scaffold/protocol/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/scaffold/protocol/README.md -README.md: 88a48957d0d44cec9f776d31eab7d25bd353de5f -README.zh.md: 6618d8838a00f945c79d7ec24b1e7491df08a3f1 +README.md: 082a890454f900aec51df123669f28814d39d601 +README.zh.md: d9b8460e51b5313f4c3a8ac66471e8cd39142430 diff --git a/packages/scaffold/protocol/README.md b/packages/scaffold/protocol/README.md index 88a48957d0..082a890454 100644 --- a/packages/scaffold/protocol/README.md +++ b/packages/scaffold/protocol/README.md @@ -22,7 +22,7 @@ The shared wire protocol for the DeepSeek Harness SDK runtime: one newline-delim | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification` (in-process runs only) | -`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. +`HarnessSdkRequestMap` and `HarnessSdkNotificationMap` index these by method name. `SessionPromptResult.messageId` identifies the queued `UserMessage`; it does not identify a later assistant message, turn ending, or prompt result. Clients combine the open-ended `session.event` stream with agent-wide `session.status` according to their own activity ownership. `SubagentFinishedNotification.lastAssistantMessage` contains the child's last non-empty assistant message or, when no such message exists, its accumulated assistant text; the field is absent when the child produced neither. `InitializeParams.maxTokens` is an optional positive safe integer that caps each conversation-model output for SDK-created agents and their in-process descendants; omission allows the selected adapter's exact-model default to apply, or otherwise preserves provider behavior. The notification payload types depend on `SessionEvent` (`dsh-session`), `ContentBlock` (`dsh-llm`), and `SubagentStopReason` (`dsh-subagent`) — the protocol streams full session-log envelopes, so the session vocabulary is part of the wire contract. `serverInfo.name` stays the wire-stable `deepseek-harness-sdk-runtime`. ## Model Experience diff --git a/packages/scaffold/protocol/README.zh.md b/packages/scaffold/protocol/README.zh.md index 6618d8838a..d9b8460e51 100644 --- a/packages/scaffold/protocol/README.zh.md +++ b/packages/scaffold/protocol/README.zh.md @@ -22,7 +22,7 @@ DeepSeek Harness SDK 运行时的共享协议格式(wire format):一个按 | server→client | `subagent.started` | `SubagentStartedNotification` | | server→client | `subagent.finished` | `SubagentFinishedNotification`(仅进程内运行) | -`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status`。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。 +`HarnessSdkRequestMap` 与 `HarnessSdkNotificationMap` 按方法名索引这些类型。`SessionPromptResult.messageId` 标识已排队的 `UserMessage`;它不标识后续的助手消息、轮次结束或提示词结果。客户端根据自己对活动区间的所有权,组合持续开放的 `session.event` 流与 agent 级的 `session.status`。`SubagentFinishedNotification.lastAssistantMessage` 包含子 agent 最后一条非空 assistant 消息;若不存在这类消息,则包含其累积的 assistant 文本;子 agent 两种输出均未产生时,该字段缺省。`InitializeParams.maxTokens` 是可选的正的安全整数,用于限制 SDK 创建的 agent 及其进程内后代的每次对话模型输出;省略时会应用所选适配器的确切模型默认值,否则提供方行为保持不变。通知载荷类型依赖 `SessionEvent`(`dsh-session`)、`ContentBlock`(`dsh-llm`)与 `SubagentStopReason`(`dsh-subagent`)——协议以完整会话日志封套进行流式传输,因此会话词汇是协议格式约定的一部分。`serverInfo.name` 的协议值固定为 `deepseek-harness-sdk-runtime`。 ## 模型体验 diff --git a/packages/scaffold/protocol/package.json b/packages/scaffold/protocol/package.json index fe7d057c11..42c326edf8 100644 --- a/packages/scaffold/protocol/package.json +++ b/packages/scaffold/protocol/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-sdk-protocol", "description": "Shared wire protocol for the DeepSeek Harness SDK runtime: the newline-delimited JSON-RPC stdio transport and the named request, result, and notification types spoken between the runtime server and SDK clients", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/scaffold/protocol" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -24,17 +31,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/scaffold/protocol/src/invariant.ts b/packages/scaffold/protocol/src/invariant.ts index c1f0b45f2d..948fb16d13 100644 --- a/packages/scaffold/protocol/src/invariant.ts +++ b/packages/scaffold/protocol/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-sdk-protocol' diff --git a/packages/scaffold/protocol/src/types.ts b/packages/scaffold/protocol/src/types.ts index dc8e11587f..16af2a76ac 100644 --- a/packages/scaffold/protocol/src/types.ts +++ b/packages/scaffold/protocol/src/types.ts @@ -85,7 +85,7 @@ export interface SubagentFinishedNotification { status: SdkRunStatus /** The provider-reported stop reason. */ stopReason: SubagentStopReason - /** The child's final assistant message, when it produced one. */ + /** The child's selected assistant output; absent when the child produced none. */ lastAssistantMessage?: ContentBlock[] } diff --git a/packages/scaffold/scripts/package.json b/packages/scaffold/scripts/package.json index deef54831f..c12396fccc 100644 --- a/packages/scaffold/scripts/package.json +++ b/packages/scaffold/scripts/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-scripts", "description": "DeepSeek Harness SDK launcher for start, dev, build, and project configuration", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/scaffold/scripts" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -41,8 +48,8 @@ }, "peerDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "tsdown": "^0.22.2", "tsx": "^4.22.4" }, @@ -57,7 +64,7 @@ "devDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "tsdown": "^0.22.2", "tsx": "^4.22.4" } diff --git a/packages/scaffold/scripts/src/invariant.ts b/packages/scaffold/scripts/src/invariant.ts index 72e97f3628..b43a6d1cf3 100644 --- a/packages/scaffold/scripts/src/invariant.ts +++ b/packages/scaffold/scripts/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-scripts' diff --git a/packages/scaffold/scripts/src/runtime.ts b/packages/scaffold/scripts/src/runtime.ts index 426ac90e5b..b0d4c8c5ad 100644 --- a/packages/scaffold/scripts/src/runtime.ts +++ b/packages/scaffold/scripts/src/runtime.ts @@ -8,7 +8,7 @@ import { register as registerHook } from 'node:module' import { access, readFile, readdir } from 'node:fs/promises' import { dirname, resolve } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { boot, installFailLoud, loadEnv, resolveConfigPath } from '@deepseek-ai/dsh-app-boot' import { parseSdkBootArgs } from './args.ts' diff --git a/packages/scaffold/scripts/tests/scripts.spec.ts b/packages/scaffold/scripts/tests/scripts.spec.ts index 6d2ea27991..a1a4a07517 100644 --- a/packages/scaffold/scripts/tests/scripts.spec.ts +++ b/packages/scaffold/scripts/tests/scripts.spec.ts @@ -323,7 +323,7 @@ describe('build profiles and invocation', () => { await writeFile(join(root, 'cordis.yml'), '[]\n') const byUrl = await startSDK(pathToFileURL(join(root, 'cordis.yml'))) await byUrl.fiber.dispose() - const byRun = await runSDK(undefined, { cwd: root }) as import('cordis').Context + const byRun = await runSDK(undefined, { cwd: root }) as import('@deepseek-ai/cordis').Context await byRun.fiber.dispose() const dev = await startSDK('./cordis.yml', { cwd: root, dev: true }) await dev.fiber.dispose() diff --git a/packages/scaffold/server/package.json b/packages/scaffold/server/package.json index 573f724ae0..0bd44281fe 100644 --- a/packages/scaffold/server/package.json +++ b/packages/scaffold/server/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-jsonrpc", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/scaffold/server" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,21 +32,21 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.17.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-llm-deepseek": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-sdk-protocol": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-llm-deepseek": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-spine-demo": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", @@ -50,6 +57,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/scaffold/server/src/index.ts b/packages/scaffold/server/src/index.ts index be7252007a..70f24dfaee 100644 --- a/packages/scaffold/server/src/index.ts +++ b/packages/scaffold/server/src/index.ts @@ -13,9 +13,9 @@ * @module @deepseek-ai/dsh-jsonrpc */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Readable, Writable } from 'node:stream' -import Schema from 'schemastery' +import Schema from '@deepseek-ai/schemastery' import { JsonRpcLineTransport } from '@deepseek-ai/dsh-sdk-protocol' import { HarnessSdkServer } from './server.ts' diff --git a/packages/scaffold/server/src/invariant.ts b/packages/scaffold/server/src/invariant.ts index 1a3c9b053b..f59a90188b 100644 --- a/packages/scaffold/server/src/invariant.ts +++ b/packages/scaffold/server/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-jsonrpc' diff --git a/packages/scaffold/server/src/server.ts b/packages/scaffold/server/src/server.ts index e6c22d7ff2..6c941509fa 100644 --- a/packages/scaffold/server/src/server.ts +++ b/packages/scaffold/server/src/server.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-jsonrpc/server */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { resolve } from 'node:path' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' @@ -216,6 +216,10 @@ export class HarnessSdkServer { } private async createSession(sessionId: string): Promise { + // No preset composition: this server's compositions keep the model-facing + // rows in the host plane, so this agent reads them from the global layer. A + // deployment that configures a roster has to join one here first + // (@deepseek-ai/dsh-agent-presets README, "Composing a child agent"). const handle = await this.ctx.agents.create({ sessionId: SessionId(sessionId), meta: { cwd: this.cwd }, diff --git a/packages/scaffold/server/tests/built-scope-carrier.e2e.ts b/packages/scaffold/server/tests/built-scope-carrier.e2e.ts index b3519110b5..a51c88ddb3 100644 --- a/packages/scaffold/server/tests/built-scope-carrier.e2e.ts +++ b/packages/scaffold/server/tests/built-scope-carrier.e2e.ts @@ -106,6 +106,8 @@ describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', ( }) expect(stderr).not.toContain('listener threw') + // A result without output omits lastAssistantMessage from the wire; it + // never sends `[]`. expect(JSON.parse(stdout) as unknown).toEqual([{ method: 'subagent.finished', params: { @@ -115,7 +117,6 @@ describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', ( childSessionId: 'built-child', status: 'ok', stopReason: 'completed', - lastAssistantMessage: [], }, }]) }) diff --git a/packages/scaffold/server/tests/plugin-apply.spec.ts b/packages/scaffold/server/tests/plugin-apply.spec.ts index b2109b8c51..1e5b9b3212 100644 --- a/packages/scaffold/server/tests/plugin-apply.spec.ts +++ b/packages/scaffold/server/tests/plugin-apply.spec.ts @@ -5,7 +5,7 @@ import { join } from 'node:path' import { tmpdir } from 'node:os' import { PassThrough, Writable } from 'node:stream' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as agentCore from '@deepseek-ai/dsh-agent-spine-demo' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' import * as jsonrpc from '../src/index.ts' diff --git a/packages/scaffold/server/tests/plugin-shape.spec.ts b/packages/scaffold/server/tests/plugin-shape.spec.ts index 97afa7d3ed..f268e4c24c 100644 --- a/packages/scaffold/server/tests/plugin-shape.spec.ts +++ b/packages/scaffold/server/tests/plugin-shape.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import Loader from '@cordisjs/plugin-loader' +import Loader from '@deepseek-ai/cordis-plugin-loader' import * as jsonrpc from '../src/index.ts' /** diff --git a/packages/scaffold/server/tests/server.spec.ts b/packages/scaffold/server/tests/server.spec.ts index 714fc0ada3..256e7bfe42 100644 --- a/packages/scaffold/server/tests/server.spec.ts +++ b/packages/scaffold/server/tests/server.spec.ts @@ -5,7 +5,7 @@ import { mkdtemp, rm } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry, { type Agent, type AgentHandle } from '@deepseek-ai/dsh-agent' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' @@ -736,6 +736,8 @@ describe('HarnessSdkServer', () => { stopReason: 'error', }) + // A result without output omits lastAssistantMessage from the wire; it + // never sends `[]`. expect(transport.notifications).toContainEqual({ method: 'subagent.finished', params: { @@ -745,7 +747,6 @@ describe('HarnessSdkServer', () => { childSessionId: 'fallback-child-session', status: 'ok', stopReason: 'max-tokens', - lastAssistantMessage: [], }, }) expect(transport.notifications).toContainEqual({ diff --git a/packages/scaffold/telemetry/package.json b/packages/scaffold/telemetry/package.json index 6fe62e35fc..4dfeb54e2b 100644 --- a/packages/scaffold/telemetry/package.json +++ b/packages/scaffold/telemetry/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-telemetry", "description": "Launcher-side dsh-sdk telemetry: secret redaction, consent resolution, anonymous id, payload builder, and fire-and-forget reporter", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/scaffold/telemetry" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -28,15 +35,15 @@ "yaml": "^2.9.0" }, "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/scaffold/telemetry/src/invariant.ts b/packages/scaffold/telemetry/src/invariant.ts index c3676a1384..bee683c50e 100644 --- a/packages/scaffold/telemetry/src/invariant.ts +++ b/packages/scaffold/telemetry/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-telemetry' diff --git a/packages/self-modification/README.i18n.yaml b/packages/self-modification/README.i18n.yaml index e6e0fa54c8..5a4ae4b3b4 100644 --- a/packages/self-modification/README.i18n.yaml +++ b/packages/self-modification/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/self-modification/README.md -README.md: c94f9ac9a79709e418448d9e440c38296e759335 -README.zh.md: 44b5d9ff4cb09fb4a4e44f446894c9925a62ec51 +README.md: 2f409f779ae32c9eedc9c57dbb6476c0639205ca +README.zh.md: 09c34c18b66803c544d7a572820c2001405fb6dd diff --git a/packages/self-modification/README.md b/packages/self-modification/README.md index c94f9ac9a7..2f409f779a 100644 --- a/packages/self-modification/README.md +++ b/packages/self-modification/README.md @@ -2,9 +2,8 @@ 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 — plus the restricted repository Plugin runtime. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.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. The group is the landing zone for future self-modification packages. Design home: [the toolset Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md). | Package | Role | ctx key | |---|---|---| | [`tool-cordis/`](tool-cordis/README.md) | Model-facing runtime inspection and temporary-plugin tools | registers on `ctx.tools` | -| [`repository-plugin/`](repository-plugin/README.md) | Repository skill and MCP composition | registers a Loader builtin | diff --git a/packages/self-modification/README.zh.md b/packages/self-modification/README.zh.md index 44b5d9ff4c..09c34c18b6 100644 --- a/packages/self-modification/README.zh.md +++ b/packages/self-modification/README.zh.md @@ -2,9 +2,8 @@ [English](README.md) | 中文 -agent 修改自身运行时:检查已加载的插件与服务接口、挂载模型编写的插件并再次 dispose,外加受限 repository Plugin 运行时。设计居所:[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 +agent 修改自身运行时:检查已加载的插件与服务接口、挂载模型编写的插件并再次 dispose。该组是未来自我修改类包的落点。设计居所:[工具集 Agent Note](../../.agents/notes/implemented/feature/2026-07-08-self-referential-cordis-toolset.md)。 | 包(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/self-modification/repository-plugin/README.md b/packages/self-modification/repository-plugin/README.md deleted file mode 100644 index 666f00e02b..0000000000 --- a/packages/self-modification/repository-plugin/README.md +++ /dev/null @@ -1,128 +0,0 @@ -# @deepseek-ai/dsh-repository-plugin - -English | [中文](README.zh.md) - -Trusted repository package format for DeepSeek Harness. A `.dsh-plugin` npm package may contribute a compiled Cordis/DSH Plugin entry, skill roots, and a common `.mcp.json`; its ordinary `prepack` lifecycle owns dependency installation and source compilation before the DSH prepare helper validates the outputs and emits the Loader wrapper. Static contributions compose [`dsh-skill-local`](../../skill/skill-local/README.md) and [`dsh-mcp-client`](../../mcp/mcp-client/README.md). Design rationale: [trusted repository package code](../../../.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md) and the [static contribution subformat](../../../.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, - "type": "module", - "scripts": { - "build": "tsc", - "prepack": "npm run build && dsh-plugin-prepare" - }, - "dsh": { - "entry": "./lib/plugin.js", - "skills": ["../skills"], - "mcpServers": "../.mcp.json" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0" - }, - "devDependencies": { - "@deepseek-ai/dsh-repository-plugin": "^0.0.1", - "typescript": "6.0.3" - } -} -``` - -`scripts.prepack` must be non-empty and invoke `dsh-plugin-prepare`; it may run arbitrary package-owned build steps first. The package declares `@deepseek-ai/dsh-repository-plugin` as an ordinary development dependency so its published executable is available to that lifecycle. DSH does not inject the helper: the repository package declares and runs its own compiler, runtime dependencies, preparation helper, and other npm lifecycle code. The selected package is installed from its own manifest instead of inheriting an enclosing pnpm workspace, so declare every dependency it needs and do not depend on workspace-only hoisting. DSH does not transpile TypeScript or infer a package entry. - -`dsh.entry` is an optional relative path to a compiled ESM Cordis Plugin inside `.dsh-plugin`. The module may use either namespace exports or a default export and owns its ordinary `name`, `inject`, `Config`, registrations, and effects. `dsh.skills` is an optional array of local skill roots, and `dsh.mcpServers` is an optional path to one `.mcp.json`; at least one of the three fields is required. Skill and MCP paths may reach adjacent repository assets but must remain beneath the directory containing `.dsh-plugin`; the compiled entry must remain inside the package selected and packed by the package manager. A repository containing several Plugins gives each one its own `.dsh-plugin` package under a different selectable subdirectory. - -The repository package and every dependency or lifecycle script it runs are trusted code, just like an npm package selected directly by the user. This format is not a sandbox: install only repositories whose code may access the host process, filesystem, network, and services declared through Cordis. Exact refs and the immutable cache provide identity and reproducibility, not isolation. - -## Standalone app configuration - -The shipped `dsh-base` bundle every profile starts from contains an empty `repository-plugins` row. A user enables exact GitHub generations by replacing that row's config in a user patch layer — `$DSH_HOME/profiles//cordis.patch.yml`, or the home-level `$DSH_HOME/cordis.patch.yml` shared by every profile; a `--patch` overlay patches the same row for one run: - -```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. - -Git transport uses the host's ordinary Git authentication. Public repositories need no credentials; private sources require a read-only credential or SSH agent that can read the selected repository. DSH removes credential-shaped environment variables before package lifecycles, so configure Git itself, such as through a credential helper or job-scoped Git config, instead of expecting an exported token variable to cross that boundary. Repository lifecycle code is trusted and can invoke Git, so use the narrowest repository-scoped credential available. - -Long-lived surfaces watch both `cordis.patch.yml` layers 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)`. One-shot runs read the layers only at startup, and a `--patch` overlay is never watched. 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 - -During exact Git installation, DSH's bundled pnpm installs the selected package from its own manifest. A transaction-owned `pnpm` wrapper reinvokes the same pinned pnpm with `--ignore-workspace`, so an enclosing workspace lockfile cannot suppress dependencies declared only by the selected `.dsh-plugin` package. The required `prepack` lifecycle runs after that dependency installation and before the selected subdirectory is packed; its ordinary `node_modules/.bin` lookup obtains `dsh-plugin-prepare` from the declared direct development dependency on `@deepseek-ai/dsh-repository-plugin`. That package marks its Cordis/DSH runtime peers optional so using the executable alone does not install the runtime graph. Package-owned commands may build TypeScript or other source before invoking the helper. The helper validates `package.json#dsh`, verifies that the compiled entry is an in-package file, validates skill and MCP sources, copies static assets under `dsh-plugin-assets`, and writes `dsh-plugin.mjs`. Before importing that wrapper, DSH revalidates that the installed package retained both the direct development dependency and a `prepack` declaration containing the helper command. Failure to resolve the published helper, install dependencies, build, or prepare fails before a cache generation is published. Rationale: [npm-backed Git source preparation Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md). - -## Runtime composition - -Loading this package registers one effect-scoped Loader builtin. Each generated wrapper delegates its prepared static manifest to that builtin, then imports and mounts `dsh.entry` when declared. The wrapper can statically gate only the `loader`, `skills`, and `tools` services implied by the prepared manifest; the entry's own `inject` is discovered when that child is mounted. The entry must reach `ACTIVE`, so a missing entry-only service or startup failure rejects the repository generation instead of committing an inert child, and all effects disappear on Loader removal or rollback. The runtime likewise validates every declared skill root as an existing in-package directory before mounting — a package whose generated outputs were dropped by `files`/`.npmignore` or damaged in cache fails instead of silently losing contributions. Repository skill roots mount as uniquely named `dsh-skill-local` providers with default project/user roots excluded and watching disabled; cached package generations are immutable. - -## 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. Repository-declared servers enable its strict startup mode: Plugin activation waits for the initial connection and tool synchronization, so the first model request observes a fully registered initial tool generation, while a network, child-process, discovery, or registration failure rejects the candidate repository generation instead of silently activating without its declared tools. - -## 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. - -### Repository code - -#### What the model sees - -Data-dependent. The trusted Cordis entry may contribute any DSH behavior available through its declared services and events, including tools, prompt sections, policies, commands, and transformations. Every model-visible contribution remains subject to its owning DSH seam's logging and lifecycle contract. - -#### Token effect - -Defined by the services and registrations the entry contributes; the repository format itself adds no model content. - -#### KV Cache effect - -Stable registrations preserve the owning surface's normal prefix behavior. Loading, removing, or replacing the exact repository generation can change any prefixes affected by that Plugin. - -## Known Limitations and Deferred Work - -- **No code sandbox** — `dsh.entry`, npm dependencies, and package lifecycle scripts execute with the DSH host's authority; repository trust is mandatory. -- **Entry-only service dependencies are not pre-gated** — the generated wrapper cannot declare an entry module's `inject` before importing it. Any service beyond those implied by Skills or MCP must already exist when the wrapper mounts the entry, or that repository generation rejects. -- **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/self-modification/repository-plugin/README.zh.md b/packages/self-modification/repository-plugin/README.zh.md deleted file mode 100644 index b09f68bc17..0000000000 --- a/packages/self-modification/repository-plugin/README.zh.md +++ /dev/null @@ -1,128 +0,0 @@ -# @deepseek-ai/dsh-repository-plugin - -[English](README.md) | 中文 - -这是 DeepSeek Harness 的受信任 repository 包格式。`.dsh-plugin` NPM 包可以贡献已编译的 Cordis/DSH 插件入口、skill(技能)根和通用 `.mcp.json`;其常规 `prepack` 生命周期负责安装依赖并编译源码,随后 DSH 准备辅助程序校验输出并生成 Loader 包装层。静态贡献由 [`dsh-skill-local`](../../skill/skill-local/README.md) 与 [`dsh-mcp-client`](../../mcp/mcp-client/README.md) 组合。设计依据见[受信任 repository 包代码](../../../.agents/notes/implemented/architecture/2026-08-08-trusted-repository-package-code.md)和[静态贡献子格式](../../../.agents/notes/implemented/architecture/2026-07-30-static-repository-plugin-format.md)。 - -## 创作格式 - -在仓库的 `.dsh-plugin` 目录中放置一个普通包: - -```json -{ - "name": "humanize-dsh-plugin", - "version": "0.0.0", - "private": true, - "type": "module", - "scripts": { - "build": "tsc", - "prepack": "npm run build && dsh-plugin-prepare" - }, - "dsh": { - "entry": "./lib/plugin.js", - "skills": ["../skills"], - "mcpServers": "../.mcp.json" - }, - "dependencies": { - "@modelcontextprotocol/sdk": "1.29.0" - }, - "devDependencies": { - "@deepseek-ai/dsh-repository-plugin": "^0.0.1", - "typescript": "6.0.3" - } -} -``` - -`scripts.prepack` 必须非空并调用 `dsh-plugin-prepare`;可以先运行任意包自有的构建步骤。包将 `@deepseek-ai/dsh-repository-plugin` 声明为普通开发依赖,使该生命周期可以使用其已发布的可执行文件。DSH 不会注入辅助程序:repository 包自行声明并运行编译器、运行时依赖、准备辅助程序及其他 NPM 生命周期代码。所选包按自身 manifest 独立安装,而不继承外层 pnpm workspace,因此必须声明所需的每项依赖,不能依赖仅由 workspace 提升而可见的包。DSH 不转译 TypeScript,也不推断包入口。 - -`dsh.entry` 是指向 `.dsh-plugin` 内已编译 ESM Cordis 插件的可选相对路径。该模块可以使用 namespace 导出或 default export,并自行拥有常规的 `name`、`inject`、`Config`、注册和 effect。`dsh.skills` 是可选的本地 skill 根数组,`dsh.mcpServers` 是指向一个 `.mcp.json` 的可选路径;三个字段中至少声明一个。skill 和 MCP 路径可以引用相邻的 repository 资源,但必须留在包含 `.dsh-plugin` 的目录下;已编译入口必须留在由包管理器选中并打包的包内。一个仓库可以在不同的可选择子目录下放置多个各自独立的 `.dsh-plugin` 包。 - -repository 包及其运行的每项依赖或生命周期脚本都是受信任代码,与用户直接选择的 NPM 包相同。本格式不是沙箱:只有在你信任仓库代码并愿意允许其访问宿主进程、文件系统、网络及其通过 Cordis 声明的服务时才应安装。精确 ref 和不可变缓存提供身份与可复现性,而非隔离。 - -## 独立应用配置 - -随附的 `dsh-base` 组合包是每个 profile 的起点,其中包含一个空 `repository-plugins` 配置项。用户可在用户 patch 层中替换该配置项的配置来启用精确指定的 GitHub generation:写入 `$DSH_HOME/profiles//cordis.patch.yml`,或写入各 profile 共享的 home 级 `$DSH_HOME/cordis.patch.yml`;`--patch` overlay 则只为单次运行 patch 同一配置项: - -```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`。 - -Git 传输使用宿主的常规 Git 认证。公共仓库无需凭据;私有源需要可读取所选仓库的只读凭据或 SSH agent。DSH 会在包生命周期运行前移除名称符合凭据模式的环境变量,因此请配置 Git 本身,例如使用 Git 凭据辅助工具或作业作用域的 Git 配置,而不要指望已导出的 token 变量跨越该边界。仓库生命周期代码受信任且可以调用 Git,因此请使用作用域最窄且仅限所选仓库的凭据。 - -长期运行的 surface 通过 Cordis HMR(热模块替换)监视两个 `cordis.patch.yml` 层。有效的源列表变更会安装并替换整套 repository Plugin generation;拉取、准备、导入或插件应用失败时,最后一个可用树保持运行,并广播 `hmr/config-update-failed(filename, error)`。一次性运行只在启动时读取这些层,`--patch` overlay 则从不被监视。相同的源字符串会永久复用其已准备缓存条目,因此必须改变 ref、路径或其他源配置,才能选择发生变化的代码。应用集成依据见[仅凭配置接入 repository Plugin 的 Agent Note](../../../.agents/notes/implemented/feature/2026-07-30-config-only-repository-plugins.md)。 - -## 准备阶段 - -安装精确指定的 Git 源时,DSH 随附的 pnpm 会按所选包自身的 manifest 安装。由事务持有的 `pnpm` 包装脚本会以 `--ignore-workspace` 重新调用同一份锁定的 pnpm,因此外层 workspace lockfile 无法抑制仅由所选 `.dsh-plugin` 包声明的依赖。必需的 `prepack` 生命周期在该依赖安装完成后、选定子目录打包前运行;其常规 `node_modules/.bin` 查找会从直接声明的 `@deepseek-ai/dsh-repository-plugin` 开发依赖中取得 `dsh-plugin-prepare`。该包把 Cordis/DSH 运行时对等依赖(peer dependency)标为可选,因此单独使用该可执行文件不会安装运行时依赖图。包自有命令可以在调用辅助程序前构建 TypeScript 或其他源码。辅助程序会校验 `package.json#dsh`,确认已编译入口是包内文件,校验 skill 与 MCP 源,把静态资源复制到 `dsh-plugin-assets`,并写入 `dsh-plugin.mjs`。导入该包装层前,DSH 会重新校验已安装包是否仍同时保留该直接开发依赖,以及包含该辅助命令的 `prepack` 声明。无法解析已发布的辅助程序,或安装依赖、构建或准备失败时,流程会在发布缓存 generation 前失败。设计依据见[基于 NPM 的 Git 源准备 Agent Note](../../../.agents/notes/implemented/bug-fix/2026-08-08-npm-backed-git-repository-plugin-preparation.md)。 - -## 运行时组合 - -加载本包会注册一个 effect-scoped Loader builtin。每个生成的包装层都把已准备的静态 manifest(元数据清单)委托给该 builtin,再在声明了 `dsh.entry` 时导入并挂载该入口。包装层只能静态门控已准备 manifest 所隐含的 `loader`、`skills` 与 `tools` 服务;入口自身的 `inject` 要到挂载该子级时才会发现。入口必须进入 `ACTIVE`,因此缺少入口专用服务或启动失败时,会拒绝 repository generation,而不会提交未激活的子级;Loader 移除或回滚时,所有 effect 都会消失。运行时同样会在挂载前校验每个声明的 skill 根都是包内实际存在的目录——生成输出因 `files`/`.npmignore` 被丢弃或在缓存中损坏的包会加载失败,而不是静默丢失贡献。Repository skill 根以唯一命名的 `dsh-skill-local` 提供方挂载,排除默认项目/用户根并禁用监视;缓存包 generation 是不可变的。 - -## 通用 MCP 格式 - -`.mcp.json` 根对象是 `{ "mcpServers": { ... } }`。stdio 条目只接受可选的 `type: "stdio"`、`command`、`args` 和 `env`;HTTP 条目只接受 `type: "http"`、`url` 和 `headers`。字符串值在插件加载时支持严格的 `${NAME}` 进程环境变量展开;缺失变量会使该次加载失败。HTTP URL 映射到现有 MCP client 的 `streamable-http` transport;stdio 条目以已准备的包目录作为 `cwd`。 - -未知字段会被拒绝,包括 OAuth 字段与 `auth` 对象。不提供 `CLAUDE_PLUGIN_ROOT` 展开或兼容层。完成格式转换后,现有 `dsh-mcp-client` 独占 transport 创建、连接诊断、工具同步、调用和断开生命周期。Repository 声明的 server 会启用其严格启动模式:插件激活会等待初始连接与工具同步,因此首个模型请求会看到已完整注册的初始工具 generation;网络、子进程、发现或注册失败则会拒绝候选 repository generation,而不是在缺少已声明工具的情况下静默激活。 - -## 导出形状 - -Namespace 插件:具名导出 `name`/`inject`/`apply`、准备阶段常量和 `prepareDshPlugin`,不提供 default export。本包还提供 `dsh-plugin-prepare` 可执行文件和 invariant companion。 - -## 模型体验 - -### Repository skill - -#### 模型看到什么 - -通过 `dsh-tool-skill` 间接呈现:已准备且允许模型调用的 skill 会按其声明的名称和描述进入该消费方记录到日志的目录及所选指令正文表面。消费方的确切 schema 见生成的 [`skill` 工具目录](../../../docs/tool-catalog.md#deepseek-aidsh-tool-skill)。 - -#### Token 影响 - -有条件且随数据变化:每个可见的 repository skill 增加一行受限长度的目录项;加载一个 skill 会把其当前完整指令正文和资源基址指引加入保留的工具历史。 - -#### KV Cache 影响 - -稳定的已准备插件集合保持前缀稳定。添加、移除或替换 repository 插件可能使消费方追加替换目录,并影响后续请求前缀。 - -### Repository MCP 工具 - -#### 模型看到什么 - -通过 `dsh-mcp-client` 间接呈现:每个已连接 server 都贡献带 server 限定名的工具 schema;调用会保留该 client 的规范 MCP 结果和渲染。 - -#### Token 影响 - -取决于连接成功和远端工具列表;schema 会在当前工具视图中的请求上重复出现,而调用与结果会留在历史中直至压缩(compaction)。 - -#### KV Cache 影响 - -稳定的已连接工具列表保持前缀稳定。插件生命周期或 MCP 工具列表变化可能从首个受影响定义开始改变后续工具 schema 前缀。 - -### Repository 代码 - -#### 模型看到什么 - -取决于数据。受信任的 Cordis 入口可以通过其声明的服务和事件贡献任意可用的 DSH 行为,包括工具、提示词片段、策略、命令和转换。每项模型可见贡献仍受所属 DSH seam 的日志与生命周期约定约束。 - -#### Token 影响 - -由入口贡献的服务和注册决定;repository 格式本身不添加模型内容。 - -#### KV Cache 影响 - -稳定的注册会保留所属表面的正常前缀行为。加载、移除或替换精确的 repository generation,可能改变受该插件影响的任意前缀。 - -## 已知限制与暂缓事项 - -- **没有代码沙箱**:`dsh.entry`、NPM 依赖和包生命周期脚本以 DSH 宿主权限执行;必须信任该 repository。 -- **入口专用服务依赖不会预先门控**:生成的包装层无法在导入入口模块前声明其 `inject`。除 skill 或 MCP 隐含的服务外,其他任何服务在包装层挂载入口时都必须已经存在,否则该 repository generation 会被拒绝。 -- **没有 MCP 认证协议**:静态 header 可以使用环境变量展开,但带 OAuth 的定义会被拒绝,私有 server 登录流程不在此实现。 -- **生成资源是不可变运行时输入**:repository cache generation 不受监视;必须改变 source、ref、path 或配置才能选择另一份已准备 generation。 diff --git a/packages/self-modification/repository-plugin/package.json b/packages/self-modification/repository-plugin/package.json deleted file mode 100644 index f20ec986c2..0000000000 --- a/packages/self-modification/repository-plugin/package.json +++ /dev/null @@ -1,73 +0,0 @@ -{ - "name": "@deepseek-ai/dsh-repository-plugin", - "description": "Trusted repository package 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" - ], - "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-paths": "^0.0.1", - "@deepseek-ai/dsh-skill-local": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "peerDependenciesMeta": { - "@cordisjs/plugin-loader": { - "optional": true - }, - "@deepseek-ai/dsh-invariants": { - "optional": true - }, - "@deepseek-ai/dsh-mcp-client": { - "optional": true - }, - "@deepseek-ai/dsh-paths": { - "optional": true - }, - "@deepseek-ai/dsh-skill-local": { - "optional": true - }, - "cordis": { - "optional": true - } - }, - "dependencies": { - "zod": "^4.4.3" - }, - "devDependencies": { - "@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:^", - "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" - } -} diff --git a/packages/self-modification/repository-plugin/src/bin.ts b/packages/self-modification/repository-plugin/src/bin.ts deleted file mode 100644 index a1787ff090..0000000000 --- a/packages/self-modification/repository-plugin/src/bin.ts +++ /dev/null @@ -1,12 +0,0 @@ -#!/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/self-modification/repository-plugin/src/format.ts b/packages/self-modification/repository-plugin/src/format.ts deleted file mode 100644 index 7af948595d..0000000000 --- a/packages/self-modification/repository-plugin/src/format.ts +++ /dev/null @@ -1,249 +0,0 @@ -/** - * Trusted repository-package 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 repository wrapper. */ -export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin' -/** Dependency-provided command that repository package `prepack` lifecycles must invoke. */ -export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare' -/** Published package whose direct development dependency supplies the prepare command. */ -export const REPOSITORY_PLUGIN_PACKAGE_NAME = '@deepseek-ai/dsh-repository-plugin' - -/** - * Whether a package lifecycle declaration names the preparation dependency's helper. - * @param script - package-authored lifecycle command. - * @returns true when the required helper command is present. - */ -export function hasRepositoryPrepareCommand(script: string): boolean { - return script.includes(REPOSITORY_PLUGIN_PREPARE_COMMAND) -} - -const prepackSchema = z.string().min(1).refine( - hasRepositoryPrepareCommand, - { message: `must invoke ${REPOSITORY_PLUGIN_PREPARE_COMMAND}` }, -) - -const sourceMetadataSchema = z.object({ - skills: z.array(z.string().min(1)).default([]), - mcpServers: z.string().min(1).optional(), - entry: z.string().min(1).optional(), -}).strict().refine(value => value.skills.length > 0 || value.mcpServers !== undefined || value.entry !== undefined, { - message: 'declare at least one skill root, mcpServers file, or compiled entry', -}) -const sourcePackageSchema = z.looseObject({ - name: z.string().min(1), - devDependencies: z.looseObject({ - [REPOSITORY_PLUGIN_PACKAGE_NAME]: z.string().min(1), - }), - scripts: z.looseObject({ - prepack: prepackSchema, - }), - dsh: sourceMetadataSchema, -}) -const preparedManifestSchema = z.object({ - name: z.string().min(1), - skills: z.array(z.string().min(1)), - mcpServers: z.string().min(1).optional(), - entry: z.string().min(1).optional(), -}).strict() -const preparedConfigSchema = z.object({ - // 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() - -/** Prepared manifest embedded in the generated wrapper. */ -export interface PreparedPluginManifest { - name: string - skills: string[] - mcpServers?: string - entry?: 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 }, - ...result.data.manifest.entry === undefined ? {} : { entry: result.data.manifest.entry }, - }, - } -} - -/** - * 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) -} - -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 { - // 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'], - ] - const entryHelpers = manifest.entry === undefined ? [] : [ - 'function unwrap(exports) {', - ' const value = exports?.default ?? exports', - ' return value?.__esModule ? (value.default ?? value) : value', - '}', - ] - const entryApply = manifest.entry === undefined ? [] : [ - ' const repositoryPlugin = unwrap(await import(manifest.entry))', - " await mount(ctx, repositoryPlugin, 'repository Plugin entry')", - ] - return [ - '// Generated by dsh-plugin-prepare. Do not edit.', - `const manifest = ${JSON.stringify(manifest)}`, - '// Value mirror: Cordis const enum FiberState.ACTIVE; keep aligned with dsh-repository-plugin source.ts.', - 'const FIBER_ACTIVE = 2', - `export const name = ${JSON.stringify(manifest.name)}`, - `export const inject = ${JSON.stringify(inject)}`, - ...entryHelpers, - 'async function mount(ctx, plugin, label, config) {', - ' const fiber = ctx.plugin(plugin, config)', - ' await fiber', - ' if (fiber.state !== FIBER_ACTIVE) {', - ' const missing = Object.keys(fiber.inject).filter(service => fiber.ctx.get(service) === undefined)', - " throw new Error(`${label} did not activate (waiting for services: ${missing.join(', ') || 'unknown'})`)", - ' }', - '}', - '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 mount(ctx, runtime, 'repository Plugin runtime', { baseUrl: import.meta.url, manifest })", - ...entryApply, - '}', - '', - ].join('\n') -} - -/** - * Validate and package one `.dsh-plugin` directory into copied assets plus a generated 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 prepared 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 DSH plugin package.json', 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')) - } - let entry: string | undefined - if (parsed.data.dsh.entry !== undefined) { - const entrySource = await sourcePath(pluginDirectory, pluginDirectory, parsed.data.dsh.entry, 'file') - entry = `./${relative(pluginDirectory, entrySource).split(sep).join('/')}` - } - - const manifest: PreparedPluginManifest = { - name: parsed.data.name, - skills: skillSources.map((_, index) => `${PREPARED_ASSET_DIRECTORY}/skills/${index}`), - ...mcpSource === undefined ? {} : { mcpServers: `${PREPARED_ASSET_DIRECTORY}/.mcp.json` }, - ...entry === undefined ? {} : { entry }, - } - 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/self-modification/repository-plugin/src/index.ts b/packages/self-modification/repository-plugin/src/index.ts deleted file mode 100644 index 46a020f40a..0000000000 --- a/packages/self-modification/repository-plugin/src/index.ts +++ /dev/null @@ -1,147 +0,0 @@ -/** - * Trusted repository-package runtime for code, skills, and common MCP definitions. - * @module @deepseek-ai/dsh-repository-plugin - */ - -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' -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, - parsePreparedPluginConfig, - type PreparedPluginConfig, -} from './format.ts' -import { parseMcpDocument, resolveMcpServers } from './mcp.ts' -import { - loadPreparedRepository, - resolveRepositoryCacheDirectory, - resolveRepositorySpecifier, -} from './source.ts' - -export { - PREPARED_ASSET_DIRECTORY, - PREPARED_ENTRY_FILENAME, - REPOSITORY_PLUGIN_BUILTIN, - REPOSITORY_PLUGIN_PACKAGE_NAME, - REPOSITORY_PLUGIN_PREPARE_COMMAND, - 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'] - -/** 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)) - const path = resolve(directory, configured) - 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 = 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 matches the Config union by design. - ).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 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`) - } - 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) - } - } - for (const repository of repositories) { - const plugin = await loadPreparedRepository(ctx, cache, repository) - yield plugin.dispose - } - }, 'repository-plugin runtime and sources') -} diff --git a/packages/self-modification/repository-plugin/src/mcp.ts b/packages/self-modification/repository-plugin/src/mcp.ts deleted file mode 100644 index d2294fb5c6..0000000000 --- a/packages/self-modification/repository-plugin/src/mcp.ts +++ /dev/null @@ -1,156 +0,0 @@ -/** - * Parser for the common `.mcp.json` file consumed by prepared repository plugins. - * @module - */ - -import { z } from 'zod' - -/** - * Restates dsh-mcp-client's `SERVER_NAME_PATTERN` rather than importing it: - * the prepare bin must stay a zod-only module graph (no tools service, 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 - -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 - failOnStartupError: true - } - | { - transport: 'streamable-http' - serverName: string - url: string - headers: Record - failOnStartupError: true - } - -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 ${SERVER_NAME_PATTERN.source}`) - } - 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, - failOnStartupError: true, - } - } - 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`), - failOnStartupError: true, - } - }) -} diff --git a/packages/self-modification/repository-plugin/src/source.ts b/packages/self-modification/repository-plugin/src/source.ts deleted file mode 100644 index 536025d6d7..0000000000 --- a/packages/self-modification/repository-plugin/src/source.ts +++ /dev/null @@ -1,130 +0,0 @@ -/** - * GitHub repository source validation and prepared-wrapper loading. - * @module - */ - -import { readFile } from 'node:fs/promises' -import { join, resolve } from 'node:path' -import { pathToFileURL } from 'node:url' -import type { Context, Fiber, FiberState, Plugin } from 'cordis' -import type { RepositoryCache } from '@cordisjs/plugin-loader/repository' -import { resolveDshHome } from '@deepseek-ai/dsh-paths' -import { z } from 'zod' -import { - PREPARED_ENTRY_FILENAME, - REPOSITORY_PLUGIN_PACKAGE_NAME, - REPOSITORY_PLUGIN_PREPARE_COMMAND, - hasRepositoryPrepareCommand, -} from './format.ts' - -// Value mirror: Cordis's const enum has no runtime object to import. Keep -// aligned with `packages/self-modification/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' - -// 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&]+))?$/ -const installedPackageSchema = z.looseObject({ - devDependencies: z.looseObject({ - [REPOSITORY_PLUGIN_PACKAGE_NAME]: z.string().min(1), - }), - scripts: z.looseObject({ - prepack: z.string().min(1).refine( - hasRepositoryPrepareCommand, - { message: `must invoke ${REPOSITORY_PLUGIN_PREPARE_COMMAND}` }, - ), - }), -}) - -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)) -} - -async function assertInstalledPackageMetadata(directory: string): Promise { - let value: unknown - try { - value = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as unknown - } catch (cause) { - throw new Error(`failed to read installed DSH plugin package metadata in ${directory}`, { cause }) - } - const result = installedPackageSchema.safeParse(value) - if (!result.success) { - throw new Error([ - `installed DSH plugin package must declare a non-empty scripts.prepack that invokes ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}, and declare ${JSON.stringify(REPOSITORY_PLUGIN_PACKAGE_NAME)} in devDependencies:`, - z.prettifyError(result.error), - 'Clear the matching repository cache generation before retrying the same source, or select a different exact source/ref/path after fixing the package.', - ].join('\n')) - } -} - -/** - * 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 { - await assertInstalledPackageMetadata(directory) - 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/self-modification/repository-plugin/tests/mcp-format.spec.ts b/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts deleted file mode 100644 index 709c1a2c4b..0000000000 --- a/packages/self-modification/repository-plugin/tests/mcp-format.spec.ts +++ /dev/null @@ -1,121 +0,0 @@ -import { describe, expect, it } from 'vitest' -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: { - 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: {}, - failOnStartupError: true, - }]) - }) - - 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', - failOnStartupError: true, - }]) - }) - - 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', - failOnStartupError: true, - }, - { - transport: 'streamable-http', - serverName: 'remote', - url: 'http://localhost:3000/mcp', - headers: { Authorization: 'Bearer test-token' }, - failOnStartupError: true, - }, - ]) - }) - - 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/self-modification/repository-plugin/tests/repository-plugin.spec.ts b/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts deleted file mode 100644 index 086616f2f9..0000000000 --- a/packages/self-modification/repository-plugin/tests/repository-plugin.spec.ts +++ /dev/null @@ -1,676 +0,0 @@ -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, 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' -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[] = [] - -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, - prepack = RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND, - devDependencies: Record = { - [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1', - }, -): 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', - devDependencies, - scripts: { prepack }, - 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 () => { - vi.restoreAllMocks() - vi.unstubAllEnvs() - 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}"]`) - // 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')) - .resolves.toContain('mcp.expo.dev') - }) - - it('preserves a compiled package entry and accepts a build before the package prepare command', async () => { - const root = await temporaryDirectory('compiled-entry') - const directory = await writePlugin(root, 'compiled-entry-fixture', { - entry: './lib/plugin.mjs', - }, 'npm run build && dsh-plugin-prepare') - await mkdir(join(directory, 'lib')) - await writeFile(join(directory, 'lib/plugin.mjs'), 'export default { name: "compiled-entry" }\n') - - await expect(RepositoryPlugin.prepareDshPlugin(directory)).resolves.toEqual({ - name: 'compiled-entry-fixture', - skills: [], - entry: './lib/plugin.mjs', - }) - const wrapper = await readFile(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME), 'utf8') - expect(wrapper).toContain('await import(manifest.entry)') - expect(wrapper).toContain('"entry":"./lib/plugin.mjs"') - }) - - 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 lifecycleRoot = await temporaryDirectory('wrong-lifecycle') - const lifecycle = join(lifecycleRoot, '.dsh-plugin') - await mkdir(lifecycle) - await writeFile(join(lifecycle, 'package.json'), JSON.stringify({ - name: 'wrong-lifecycle', - scripts: { prepare: 'dsh-plugin-prepare' }, - dsh: { skills: ['../skills'] }, - })) - await expect(RepositoryPlugin.prepareDshPlugin(lifecycle)).rejects.toThrow('prepack') - - const skippedPrepareRoot = await temporaryDirectory('skipped-prepare') - const skippedPrepare = await writePlugin( - skippedPrepareRoot, - 'skipped-prepare', - { skills: ['../skills'] }, - 'npm run build', - ) - await expect(RepositoryPlugin.prepareDshPlugin(skippedPrepare)).rejects.toThrow('must invoke dsh-plugin-prepare') - - const undeclaredPrepareRoot = await temporaryDirectory('undeclared-prepare-dependency') - const undeclaredPrepare = await writePlugin( - undeclaredPrepareRoot, - 'undeclared-prepare-dependency', - { skills: ['../skills'] }, - RepositoryPlugin.REPOSITORY_PLUGIN_PREPARE_COMMAND, - {}, - ) - await expect(RepositoryPlugin.prepareDshPlugin(undeclaredPrepare)) - .rejects.toThrow(RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME) - - 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, mcpServers file, or compiled entry') - - 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') - - const escapedEntryRoot = await temporaryDirectory('escaped-entry') - await writeFile(join(escapedEntryRoot, 'outside.mjs'), 'export default {}\n') - const escapedEntry = await writePlugin(escapedEntryRoot, 'escaped-entry', { entry: '../outside.mjs' }) - await expect(RepositoryPlugin.prepareDshPlugin(escapedEntry)).rejects.toThrow('escapes its plugin source root') - }) - - it('validates prepared wrapper configs with optional MCP assets and code entries', () => { - 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', entry: './lib/plugin.js' }, - })).toEqual({ - baseUrl: 'file:///plugin/dsh-plugin.mjs', - manifest: { name: 'fixture', skills: [], mcpServers: 'dsh-plugin-assets/.mcp.json', entry: './lib/plugin.js' }, - }) - }) -}) - -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('mounts and removes the repository package code entry through the real Loader', async () => { - const root = await temporaryDirectory('code-loader') - const directory = await writePlugin(root, 'code-loader-fixture', { entry: './lib/plugin.mjs' }) - await mkdir(join(directory, 'lib')) - await writeFile(join(directory, 'lib/plugin.mjs'), [ - "export const name = 'repository-code-proof'", - 'export function apply(ctx) {', - " ctx.provide('repositoryCodeProof', { source: 'compiled-entry' })", - '}', - '', - ].join('\n')) - await RepositoryPlugin.prepareDshPlugin(directory) - - const ctx = new Context() - ctx.baseUrl = pathToFileURL(directory).href + '/' - await ctx.plugin(Loader) - await ctx.plugin(RepositoryPlugin) - const id = await ctx.loader.create({ - name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, - }) - await ctx.loader.await() - const getService = (name: string): unknown => (ctx as unknown as { get(name: string): unknown }).get(name) - expect(getService('repositoryCodeProof')).toEqual({ source: 'compiled-entry' }) - - await ctx.loader.remove(id) - expect(getService('repositoryCodeProof')).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('mounts and removes tools discovered from a repository MCP server', async () => { - const root = await temporaryDirectory('mcp-loader-success') - const server = join(root, 'mcp-server.mjs') - await writeFile(server, [ - "import { createInterface } from 'node:readline'", - 'const lines = createInterface({ input: process.stdin })', - 'for await (const line of lines) {', - ' const request = JSON.parse(line)', - " if (!('id' in request)) continue", - ' let result', - " if (request.method === 'initialize') {", - ' result = {', - ' protocolVersion: request.params.protocolVersion,', - ' capabilities: { tools: {} },', - " serverInfo: { name: 'repository-fixture', version: '0.0.0' },", - ' }', - " } else if (request.method === 'tools/list') {", - ' result = {', - ' tools: [{', - " name: 'proof',", - " description: 'Repository MCP proof.',", - " inputSchema: { type: 'object', properties: {} },", - ' }],', - ' }', - ' } else {', - ' result = {}', - ' }', - " process.stdout.write(`${JSON.stringify({ jsonrpc: '2.0', id: request.id, result })}\\n`)", - '}', - '', - ].join('\n')) - await writeFile(join(root, '.mcp.json'), JSON.stringify({ - mcpServers: { online: { command: process.execPath, args: [server] } }, - })) - const directory = await writePlugin(root, 'mcp-loader-success-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.get('mcp__online__proof')).toBeDefined() - - await ctx.loader.remove(id) - expect(ctx.tools.get('mcp__online__proof')).toBeUndefined() - await ctx.fiber.dispose() - }) - - it('fails an MCP repository plugin load when its declared server cannot connect', 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) - await expect(ctx.loader.create({ - name: pathToFileURL(join(directory, RepositoryPlugin.PREPARED_ENTRY_FILENAME)).href, - })).rejects.toThrow('initial connection or tool synchronization failed') - expect(ctx.tools.schemas().some(tool => tool.name.startsWith('mcp__offline__'))).toBe(false) - 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('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) - const registrar = ctx.plugin(RepositoryPlugin) - await registrar - await expect(RepositoryPlugin.apply(ctx)).rejects.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('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#', - 'github:owner/repository#a#b', - '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('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) - 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('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 directory = await writePlugin(root, 'missing-wrapper', { skills: ['../skills'] }) - const ctx = new Context() - const specifier = 'github:owner/repository#missing&path:/.dsh-plugin' - await expect(loadPreparedRepository(ctx, { resolve: async () => directory }, specifier)) - .rejects.toThrow(`failed to load prepared repository Plugin ${JSON.stringify(specifier)}`) - await ctx.fiber.dispose() - }) - - it('rejects installed source with the obsolete prepare lifecycle', async () => { - const root = await temporaryDirectory('installed-lifecycle') - await writeFile(join(root, 'package.json'), JSON.stringify({ - name: 'installed-lifecycle', - devDependencies: { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' }, - scripts: { prepare: 'dsh-plugin-prepare' }, - })) - const ctx = new Context() - await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#old&path:/.dsh-plugin')) - .rejects.toMatchObject({ - cause: expect.objectContaining({ - message: expect.stringContaining('must declare a non-empty scripts.prepack') as string, - }) as Error, - }) - await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#old&path:/.dsh-plugin')) - .rejects.toMatchObject({ - cause: expect.objectContaining({ - message: expect.stringContaining('Clear the matching repository cache generation') as string, - }) as Error, - }) - await ctx.fiber.dispose() - }) - - it('rejects an installed source whose prepack omits the package prepare command', async () => { - const root = await temporaryDirectory('installed-skipped-prepare') - await writeFile(join(root, 'package.json'), JSON.stringify({ - name: 'installed-skipped-prepare', - devDependencies: { [RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME]: '0.0.1' }, - scripts: { prepack: 'npm run build' }, - })) - const ctx = new Context() - await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#unprepared&path:/.dsh-plugin')) - .rejects.toMatchObject({ - cause: expect.objectContaining({ - message: expect.stringContaining('must invoke dsh-plugin-prepare') as string, - }) as Error, - }) - await ctx.fiber.dispose() - }) - - it('rejects installed source without the declared prepare dependency', async () => { - const root = await temporaryDirectory('installed-missing-prepare-dependency') - await writeFile(join(root, 'package.json'), JSON.stringify({ - name: 'installed-missing-prepare-dependency', - scripts: { prepack: 'dsh-plugin-prepare' }, - })) - const ctx = new Context() - await expect(loadPreparedRepository(ctx, { resolve: async () => root }, 'github:owner/repository#ambient-helper&path:/.dsh-plugin')) - .rejects.toMatchObject({ - cause: expect.objectContaining({ - message: expect.stringContaining(`${JSON.stringify(RepositoryPlugin.REPOSITORY_PLUGIN_PACKAGE_NAME)} in devDependencies`) as string, - }) as Error, - }) - await ctx.fiber.dispose() - }) - - it('labels missing installed package metadata with its source', async () => { - const root = await temporaryDirectory('missing-installed-metadata') - const ctx = new Context() - const specifier = 'github:owner/repository#damaged&path:/.dsh-plugin' - await expect(loadPreparedRepository(ctx, { resolve: async () => root }, specifier)) - .rejects.toMatchObject({ - message: expect.stringContaining(JSON.stringify(specifier)) as string, - cause: expect.objectContaining({ - message: expect.stringContaining('failed to read installed DSH plugin package metadata') as string, - }) as Error, - }) - 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/self-modification/repository-plugin/tsdown.config.ts b/packages/self-modification/repository-plugin/tsdown.config.ts deleted file mode 100644 index ac8e9a5fe0..0000000000 --- a/packages/self-modification/repository-plugin/tsdown.config.ts +++ /dev/null @@ -1,17 +0,0 @@ -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/self-modification/tool-cordis/README.i18n.yaml b/packages/self-modification/tool-cordis/README.i18n.yaml index ef1f441711..c342e66463 100644 --- a/packages/self-modification/tool-cordis/README.i18n.yaml +++ b/packages/self-modification/tool-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/self-modification/tool-cordis/README.md -README.md: f2a65043a1d2f74553e98caf59ed3d38b5a70b7c -README.zh.md: 66742094992d219ccfbd60b935dcd10e48cb12b8 +README.md: be629ca9be5bff1e6f658f05476e8f2880804e44 +README.zh.md: 45f3536d3460106c45051093d097729a366eca1a diff --git a/packages/self-modification/tool-cordis/README.md b/packages/self-modification/tool-cordis/README.md index f2a65043a1..be629ca9be 100644 --- a/packages/self-modification/tool-cordis/README.md +++ b/packages/self-modification/tool-cordis/README.md @@ -14,7 +14,7 @@ Exact model-facing schemas: [the generated tool catalog](../../../docs/tool-cata Canonical successes are the inspection string, mount `{ id, pluginName, state, provides, waitingFor }`, and unmount `{ id, pluginName }`. Native rendering says whether the temporary Plugin is running or pending and that it remains available until unmounted or DSH restarts; unmount confirms that it was removed. -Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_unmount`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. +Temporary Plugins live only in the shared DSH process memory. They remain active across later turns and may affect other sessions in that process, but disappear after `cordis_unmount`, toolset unload, or DSH restart. They create no Plugin file, install no package, change no `cordis.yml` or personal/project configuration, do not survive restart, and cannot be promoted automatically. To keep an experiment, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. ## Trust stance @@ -87,3 +87,4 @@ Mounting or unmounting a prompt or tool contribution changes later request prefi - **The sandbox is containment for honest code, not a security boundary** — host-realm helpers on the sandbox global are reachable, so mount code can reach Node; load this plugin as deliberately as you would grant a bash tool (see § Trust stance). - **The `ctx` façade exposes no `effect()`** — mount code cannot register a bespoke disposer; `on`/`provide`/`tools.register` are the supported cleanup paths. - **`vmTimeoutMs` bounds only synchronous evaluation** — an async mount body escapes it; there is no async budget on mount code. +- **Temporary Plugins belong to the composition, not to the session that mounted one** — the group fiber and the `dyn-N` table are this row's own, so every agent the row covers shares them: registered inside an agent preset's standing mount, one session's mount is visible in another session's tool catalog and `cordis_inspect what:"temporary"`, and the second mount of an id replaces the first. Several sessions running one preset concurrently is where that becomes observable. Per-session temporary plugins would need the group and table keyed by the calling agent. diff --git a/packages/self-modification/tool-cordis/README.zh.md b/packages/self-modification/tool-cordis/README.zh.md index 6674209499..45f3536d34 100644 --- a/packages/self-modification/tool-cordis/README.zh.md +++ b/packages/self-modification/tool-cordis/README.zh.md @@ -14,7 +14,7 @@ 规范成功结果分别为检查字符串、挂载 `{ id, pluginName, state, provides, waitingFor }`,以及卸载 `{ id, pluginName }`。原生渲染会说明临时插件正在运行还是等待中,并说明它可用至被卸载或 DSH 重启;卸载结果确认它已移除。 -临时插件只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent(智能体)通过常规开发流程实现普通的本地、项目或仓库插件。 +临时插件只存在于共享 DSH 进程内存中。它可跨后续轮次保持活跃,也可能影响同一进程中的其他会话,但会在 `cordis_unmount`、工具集卸载或 DSH 重启后消失。它不会创建插件文件、安装任何包、修改 `cordis.yml` 或个人/项目配置、跨重启存续,也不能自动转为正式插件。若要保留实验结果,应让 agent(智能体)通过常规开发流程实现 SDK 插件或可安装的 profile 组合包。 ## 信任立场 @@ -87,3 +87,4 @@ Namespace 插件:命名导出 `name`/`inject`/`Config`/`apply`,无默 - **沙箱只用于约束诚实代码,并非安全边界**:可以访问沙箱全局变量上的 host realm helper,因此挂载代码可以触达 Node;加载该插件时,应当像授予 bash 工具一样慎重(见 § 信任立场)。 - **`ctx` façade 不公开 `effect()`**:挂载代码无法注册定制 disposer;`on`/`provide`/`tools.register` 是受支持的清理路径。 - **`vmTimeoutMs` 只限制同步求值**:async 挂载主体可逃出该边界;挂载代码没有 async 预算。 +- **临时 Plugin 属于组装,而不属于挂载它的那个会话**:group fiber 与 `dyn-N` 表是本行自己的,因此本行覆盖的每个 agent 共享它们——注册在某个 agent preset 的常驻挂载里时,一个会话挂载出来的东西会出现在另一个会话的工具目录和 `cordis_inspect what:"temporary"` 里,同一个 id 的第二次挂载会顶掉第一次。多个会话并发运行同一 preset 时这一点才变得可观察。要做到逐会话,需要把 group 与表按调用方 agent 建键。 diff --git a/packages/self-modification/tool-cordis/package.json b/packages/self-modification/tool-cordis/package.json index 0b1c78cf8f..66fc24d91a 100644 --- a/packages/self-modification/tool-cordis/package.json +++ b/packages/self-modification/tool-cordis/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-cordis", "description": "Self-referential cordis toolset: inspect the live runtime, mount and dispose model-written plugins", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/self-modification/tool-cordis" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -45,6 +52,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 01631bb22c..1c310ab8e0 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -432,6 +432,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract streamText(target: FsTarget, signal?: AbortSignal): Promise>', jsDoc: '/**\n * Stream the whole regular text file as decoded text chunks (same text\n * semantics as {@link readText}, for large files). The backend owns\n * cross-chunk UTF-8 decoding and binary rejection so the policy layer never\n * touches raw bytes.\n * @param target - the resolved target to read.\n * @param signal - aborts the stream, including between chunks.\n * @returns the chunk iterable, decoded and validated like {@link readText}.\n */', }, + { + signature: 'abstract readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise', + jsDoc: '/**\n * Read the whole regular file as raw bytes with no decoding or binary\n * rejection. The bound lives at this seam so a backend can never buffer an\n * unbounded file: a target known or discovered to exceed `maxBytes` fails\n * with `FS_TOO_LARGE` instead of returning a truncated result.\n * @param target - the resolved target to read.\n * @param signal - aborts the read.\n * @param maxBytes - inclusive byte cap on the complete content.\n * @returns the full raw content, at most `maxBytes` long.\n */', + }, { signature: 'abstract listDir(target: FsTarget, signal?: AbortSignal): Promise', jsDoc: '/**\n * List direct children of a directory in stable name order. Returns resolved\n * child targets plus cheap metadata only; never reads file contents.\n * @param target - the resolved directory target.\n * @param signal - aborts the listing.\n * @returns one entry per direct child, in stable name order.\n */', @@ -582,6 +586,24 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, ], }, + { + key: 'messageFeedback', + summary: 'Storage-domain sidecar service.', + methods: [ + { + signature: '@Remote(\'list\') async list(request: MessageFeedbackListRequest): Promise', + jsDoc: '/**\n * Read feedback belonging to the current persisted Session lifecycle.\n * A stale row from a reused Session id is invisible.\n * @param request - Session identity to inspect and list.\n * @returns current immutable items or `session-not-found`.\n */', + }, + { + signature: '@Remote(\'put\') put(request: MessageFeedbackPutRequest): Promise', + jsDoc: '/**\n * Create or replace feedback for one derived append-origin assistant\n * message. Every request must match the addressed item\'s current version;\n * a matching no-op returns the stored item without changing its revision.\n * @param request - target, desired value, and observed item version.\n * @returns the committed item or an explicit business failure.\n */', + }, + { + signature: '@Remote(\'delete\') delete(request: MessageFeedbackDeleteRequest): Promise', + jsDoc: '/**\n * Delete one feedback item. Absence is successful regardless of the\n * supplied version; an existing item requires an exact version match.\n * @param request - Session, message, and observed item version.\n * @returns the stable absent postcondition, or an explicit failure.\n */', + }, + ], + }, { key: 'permission', summary: 'Owns the deployment\'s permission presets and their write path.', @@ -696,6 +718,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract locate(meta: SessionHeader): SessionLocation | undefined', jsDoc: '/**\n * Resolve this backend\'s independent local artifact for a session without\n * reading, creating, flushing, or otherwise materializing it. Backends such\n * as SQLite that do not own one artifact per session return `undefined`.\n * @param meta - the immutable session header whose artifact is requested.\n * @returns the backend-specific absolute location, when one exists.\n */', }, + { + signature: 'readRaw(_id: SessionId, signal?: AbortSignal): Promise', + jsDoc: '/**\n * Read a session\'s backend-owned artifact text verbatim — the exact durable\n * bytes the backend wrote (decoded from its physical encoding, e.g. a\n * decompressed JSONL). The returned `content` is the raw text, not a\n * reconstruction from parsed events, so it preserves backend-specific\n * serialization (chunk packing, key order, line breaks). Backends without a\n * per-session artifact (SQLite) inherit the `undefined` default.\n * @param _id - the persisted session to read (unused by the default: no\n * per-session artifact).\n * @param signal - optional cancellation for backend read work.\n * @returns the raw artifact plus its parsed header, or `undefined` when the\n * session is absent or the backend owns no per-session artifact.\n */', + }, { signature: 'abstract create(meta: SessionHeader): Promise', jsDoc: '/**\n * Register a new session\'s metadata. A backend MAY defer the physical write\n * until the first {@link append} (lazy materialization), in which case a\n * created-but-never-appended session is absent from {@link list}\n * — abandoned sessions leave nothing behind.\n * @param meta - the immutable header (id, version, cwd, lineage) to record.\n */', @@ -1114,7 +1140,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'async assemble(context: AssembleContext = {}): Promise', - jsDoc: '/**\n * Assemble global and scoped providers, detach tool parameters, apply\n * canonical ordering, then run the assembly waterfall. Scoped sections and\n * variables shadow globals; the returned waterfall value is authoritative.\n * @param context - the optional scope and plugin-defined assembly fields.\n * @returns the authoritative post-waterfall assembly.\n */', + jsDoc: '/**\n * Assemble global and scoped providers, detach tool parameters, apply\n * canonical ordering, then run the assembly waterfall. Scoped sections and\n * variables shadow globals. The returned waterfall value is authoritative\n * except that an effective complete section is restored afterwards as the\n * sole prompt section.\n * @param context - the optional scope and plugin-defined assembly fields.\n * @returns the post-waterfall assembly with any complete prompt enforced.\n */', }, ], }, @@ -1150,6 +1176,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract onTaskDone(listener: TaskDoneListener): () => void', jsDoc: '/**\n * Register an effect-scoped completion listener. It receives the settlements\n * of the owners its registering context\'s scope covers; each listener is\n * contained; returned promises are observed but not awaited. No listener runs\n * after service disposal.\n * @param listener - receives each terminal snapshot and its exact owner.\n * @returns disposer that unregisters the listener.\n */', }, + { + signature: 'abstract onTasksChanged(listener: TasksChangedListener): () => void', + jsDoc: '/**\n/**\n * Register an effect-scoped observer of visible-set changes. It fires after\n * every commit that changes what {@link list} returns for that owner —\n * registration, every stopping transition (including the one teardown\n * performs before it awaits a slow producer), settlement, owner-disposal\n * removal, and the emptying that service disposal commits — so an observer\n * re-reads rather than accumulating deltas.\n *\n * Delivery is owner-relative on the same terms as {@link onTaskDone}: an\n * observer registered from an unscoped context — a host composition\'s own\n * carrier — sees every owner, while one registered under an agent\n * composition\'s scope sees exactly the agents composed under it.\n *\n * This is not a superset of {@link onTaskDone}: that one delivers the terminal\n * record under first-wins semantics a control surface couples to notice\n * delivery, while this one carries no delivery meaning and marks nothing\n * reported. Listeners are contained and never awaited.\n * @param listener - receives the owner whose visible set changed, or\n * `undefined` when an unowned task changed and every caller\'s set did.\n * @returns disposer that unregisters the listener.\n */', + }, { signature: 'abstract attachSurface(name: string): () => void', jsDoc: '/**\n * Attach an effect-scoped surface that can read and stop tasks. It serves the\n * owners its registering context\'s scope covers, and {@link start} refuses an\n * owner no attached surface serves.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */', @@ -1212,7 +1242,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ methods: [ { signature: 'presentAs(mode: ToolPresentationMode): () => void', - jsDoc: '/**\n * Present this agent\'s tools in `mode` instead of the deployment default.\n *\n * Scoped only, and one declaration per agent: this is how an agent preset\n * composes a Code Mode agent beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation this agent\'s model sees.\n * @returns the exact disposer that restores the deployment default.\n */', + jsDoc: '/**\n * Present the calling scope\'s tools in `mode` instead of the deployment\n * default. Nearest scope on the chain wins, so a preset\'s standing\n * declaration covers every agent joined under it.\n *\n * Scoped only, and one declaration per scope: this is how an agent preset\n * composes Code Mode agents beside native ones in the same process, and a\n * process-global override would be the `mode` config field instead.\n * @param mode - the presentation the covered agents\' models see.\n * @returns the exact disposer that restores the deployment default.\n */', }, { signature: 'register(definition: ToolDefinition): () => void', @@ -1610,7 +1640,7 @@ export const EVENT_API: readonly EventApiEntry[] = [ name: 'system-prompt/assemble', mode: 'waterfall', signature: '\'system-prompt/assemble\'(this: Scoped, assembly: PromptAssembly, context: AssembleContext, next: () => Promise): Promise', - jsDoc: '/**\n * Expert waterfall over the assembled sections, contexts, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */', + jsDoc: '/**\n * Expert waterfall over the assembled sections, contexts, tools, and variables.\n * Scope-filtered dispatch (`@deepseek-ai/dsh-scope`): scoped listeners\n * receive only that scope\'s assemblies. The returned value is authoritative.\n * A supplied signal controls only this explicit assembly request and must not\n * be retained to control later turns. A registered complete section is\n * restored after this waterfall, so listeners cannot add to or replace\n * that scope\'s system prompt.\n * @param assembly - the mutable assembly built from registered providers.\n * @param context - the caller\'s per-assembly context.\n * @mode waterfall\n */', summary: 'Expert waterfall over the assembled sections, contexts, tools, and variables.', }, { @@ -2327,6 +2357,82 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'Message', declaration: 'export interface Message {\n readonly id: MessageId;\n readonly role: \'system\' | \'user\' | \'assistant\';\n readonly content: ContentBlock[];\n readonly source: MessageSource;\n}', }, + { + name: 'MessageFeedbackDeleteRequest', + declaration: 'export interface MessageFeedbackDeleteRequest {\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly ifVersion: MessageFeedbackVersion;\n}', + }, + { + name: 'MessageFeedbackDeleteResult', + declaration: 'export type MessageFeedbackDeleteResult = MessageFeedbackSuccess | MessageFeedbackRejected;', + }, + { + name: 'MessageFeedbackDeleteValue', + declaration: 'export interface MessageFeedbackDeleteValue {\n readonly absent: true;\n}', + }, + { + name: 'MessageFeedbackFailure', + declaration: 'export type MessageFeedbackFailure = MessageFeedbackSessionNotFound | MessageFeedbackTargetNotFound | MessageFeedbackVersionConflict | MessageFeedbackNoteBlank | MessageFeedbackNoteTooLarge;', + }, + { + name: 'MessageFeedbackItem', + declaration: 'export interface MessageFeedbackItem {\n readonly messageId: MessageId;\n readonly rating: MessageFeedbackRating;\n readonly note?: string;\n readonly version: MessageFeedbackVersion;\n readonly createdAt: number;\n readonly updatedAt: number;\n}', + }, + { + name: 'MessageFeedbackListRequest', + declaration: 'export interface MessageFeedbackListRequest {\n readonly sessionId: SessionId;\n}', + }, + { + name: 'MessageFeedbackListResult', + declaration: 'export type MessageFeedbackListResult = MessageFeedbackSuccess | MessageFeedbackRejected;', + }, + { + name: 'MessageFeedbackListValue', + declaration: 'export interface MessageFeedbackListValue {\n readonly items: readonly MessageFeedbackItem[];\n}', + }, + { + name: 'MessageFeedbackNoteBlank', + declaration: 'export interface MessageFeedbackNoteBlank {\n readonly code: \'note-blank\';\n}', + }, + { + name: 'MessageFeedbackNoteTooLarge', + declaration: 'export interface MessageFeedbackNoteTooLarge {\n readonly code: \'note-too-large\';\n readonly maxBytes: number;\n readonly actualBytes: number;\n}', + }, + { + name: 'MessageFeedbackPutRequest', + declaration: 'export interface MessageFeedbackPutRequest {\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n readonly rating: MessageFeedbackRating;\n readonly note?: string;\n readonly ifVersion: MessageFeedbackVersion | null;\n}', + }, + { + name: 'MessageFeedbackPutResult', + declaration: 'export type MessageFeedbackPutResult = MessageFeedbackSuccess | MessageFeedbackRejected;', + }, + { + name: 'MessageFeedbackRating', + declaration: 'export type MessageFeedbackRating = \'positive\' | \'negative\';', + }, + { + name: 'MessageFeedbackRejected', + declaration: 'export interface MessageFeedbackRejected {\n readonly ok: false;\n readonly error: E;\n}', + }, + { + name: 'MessageFeedbackSessionNotFound', + declaration: 'export interface MessageFeedbackSessionNotFound {\n readonly code: \'session-not-found\';\n readonly sessionId: SessionId;\n}', + }, + { + name: 'MessageFeedbackSuccess', + declaration: 'export interface MessageFeedbackSuccess {\n readonly ok: true;\n readonly value: T;\n}', + }, + { + name: 'MessageFeedbackTargetNotFound', + declaration: 'export interface MessageFeedbackTargetNotFound {\n readonly code: \'target-not-found\';\n readonly sessionId: SessionId;\n readonly messageId: MessageId;\n}', + }, + { + name: 'MessageFeedbackVersion', + declaration: 'export type MessageFeedbackVersion = Branded<\'MessageFeedbackVersion\'>;', + }, + { + name: 'MessageFeedbackVersionConflict', + declaration: 'export interface MessageFeedbackVersionConflict {\n readonly code: \'version-conflict\';\n readonly current: MessageFeedbackItem | null;\n}', + }, { name: 'MessageId', declaration: 'export type MessageId = Branded<\'MessageId\'>;', @@ -2421,7 +2527,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'PromptSection', - declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n}', + declaration: 'export interface PromptSection {\n readonly name: string;\n readonly order: number;\n readonly text: string | ((context: AssembleContext) => string);\n readonly complete?: boolean;\n}', }, { name: 'ProviderRequestId', @@ -2633,7 +2739,7 @@ export const TYPE_API: readonly TypeApiEntry[] = [ }, { name: 'SessionEvent', - declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', + declaration: 'export type SessionEvent = {\n [K in SessionEventType]: {\n type: K;\n seq: number;\n time: number;\n data: SessionEventMap[K];\n ignorable?: true;\n } & (K extends SurfaceEventType ? {\n sourceEventSeqs?: number[];\n surfaceOp?: SurfaceOp;\n } : object);\n}[T];', }, { name: 'SessionEventMap', @@ -2747,6 +2853,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'SessionProjectionMap', declaration: 'export interface SessionProjectionMap {\n}', }, + { + name: 'SessionRawArtifact', + declaration: 'export interface SessionRawArtifact {\n readonly meta: SessionHeader;\n readonly filename: string;\n readonly content: string;\n}', + }, { name: 'SessionRecord', declaration: 'export interface SessionRecord {\n header: SessionHeader;\n live: boolean;\n persisted: boolean;\n}', @@ -3115,6 +3225,10 @@ export const TYPE_API: readonly TypeApiEntry[] = [ name: 'TaskRead', declaration: 'export interface TaskRead {\n text: string;\n snapshot: TaskSnapshot;\n}', }, + { + name: 'TasksChangedListener', + declaration: 'export type TasksChangedListener = (owner: Agent | undefined) => void;', + }, { name: 'TaskSnapshot', declaration: 'export interface TaskSnapshot {\n id: TaskId;\n kind: TaskKind;\n label: string;\n outputLimitBytes?: number;\n ownerSession?: SessionId;\n status: TaskStatus;\n detail?: string;\n startedAt: number;\n finishedAt?: number;\n reported: boolean;\n}', diff --git a/packages/self-modification/tool-cordis/src/fiber-state.ts b/packages/self-modification/tool-cordis/src/fiber-state.ts index dcd9da149b..2c9dbfd240 100644 --- a/packages/self-modification/tool-cordis/src/fiber-state.ts +++ b/packages/self-modification/tool-cordis/src/fiber-state.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-tool-cordis/fiber-state */ -import type { FiberState as FiberStateEnum } from 'cordis' +import type { FiberState as FiberStateEnum } from '@deepseek-ai/cordis' /** Value mirror of the cordis `FiberState` const enum (see the module doc for why a mirror exists). */ export const FiberState = { diff --git a/packages/self-modification/tool-cordis/src/guard.ts b/packages/self-modification/tool-cordis/src/guard.ts index 22d85936f4..7724e339be 100644 --- a/packages/self-modification/tool-cordis/src/guard.ts +++ b/packages/self-modification/tool-cordis/src/guard.ts @@ -12,8 +12,8 @@ * @module @deepseek-ai/dsh-tool-cordis/guard */ -import { Context } from 'cordis' -import type { Plugin } from 'cordis' +import { Context } from '@deepseek-ai/cordis' +import type { Plugin } from '@deepseek-ai/cordis' import { scopeOf } from '@deepseek-ai/dsh-scope' import { assertSupportedJsonSchema, defineTool } from '@deepseek-ai/dsh-tools' import type { ToolDefinition } from '@deepseek-ai/dsh-tools' diff --git a/packages/self-modification/tool-cordis/src/index.ts b/packages/self-modification/tool-cordis/src/index.ts index 6ba56ccfe4..272ed800a0 100644 --- a/packages/self-modification/tool-cordis/src/index.ts +++ b/packages/self-modification/tool-cordis/src/index.ts @@ -7,8 +7,8 @@ * @module @deepseek-ai/dsh-tool-cordis */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import { STATE_LABELS } from './fiber-state.ts' import { isPlugin, pluginName } from './guard.ts' @@ -112,7 +112,7 @@ export function apply(ctx: Context, config: Config): void { + 'This creates an in-memory runtime Plugin, not an installed or configured Plugin. ' + 'It remains active across later turns until cordis_unmount, toolset unload, or DSH restart. ' + 'It does not create files, install a package, change cordis.yml or personal/project config, survive restart, or automatically become permanent. ' - + 'To keep it, ask the Agent to implement a normal local, project, or repository Plugin through the regular development workflow. ' + + 'To keep it, ask the Agent to implement an SDK Plugin or installable profile bundle through the regular development workflow. ' + 'It may affect other sessions in the same process; the sandbox is not a security boundary, and injected services reach the real runtime. ' + '`code` runs now as the body of an async JavaScript function ' + 'in an isolated sandbox and MUST `return` a plugin. Two forms: ' diff --git a/packages/self-modification/tool-cordis/src/inspect.ts b/packages/self-modification/tool-cordis/src/inspect.ts index 08bbeb44cf..c787be72d2 100644 --- a/packages/self-modification/tool-cordis/src/inspect.ts +++ b/packages/self-modification/tool-cordis/src/inspect.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-tool-cordis/inspect */ -import type { Context, Fiber } from 'cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import type { ScopeKey } from '@deepseek-ai/dsh-scope' import { EVENT_API, INHERITED_CTX_API, SERVICE_API, TYPE_API } from './api-catalog.ts' import type { EventApiEntry, InheritedApiEntry, ServiceApiEntry, TypeApiEntry } from './api-catalog.ts' diff --git a/packages/self-modification/tool-cordis/src/invariant.ts b/packages/self-modification/tool-cordis/src/invariant.ts index 6fd73d0353..3b58f40fd7 100644 --- a/packages/self-modification/tool-cordis/src/invariant.ts +++ b/packages/self-modification/tool-cordis/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-cordis' diff --git a/packages/self-modification/tool-cordis/src/mount.ts b/packages/self-modification/tool-cordis/src/mount.ts index 942d45eb79..45e0a429fb 100644 --- a/packages/self-modification/tool-cordis/src/mount.ts +++ b/packages/self-modification/tool-cordis/src/mount.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-tool-cordis/mount */ -import type { Context, Fiber, Plugin } from 'cordis' +import type { Context, Fiber, Plugin } from '@deepseek-ai/cordis' import { guardedPlugin } from './guard.ts' /** One tracked dynamic mount: the fiber plus the display name captured at mount time. */ diff --git a/packages/self-modification/tool-cordis/tests/cordis-lifecycle.spec.ts b/packages/self-modification/tool-cordis/tests/cordis-lifecycle.spec.ts index b290ae998e..e5bba585a7 100644 --- a/packages/self-modification/tool-cordis/tests/cordis-lifecycle.spec.ts +++ b/packages/self-modification/tool-cordis/tests/cordis-lifecycle.spec.ts @@ -1,4 +1,4 @@ -import { Context, CordisError, FiberState, type Fiber } from 'cordis' +import { Context, CordisError, FiberState, type Fiber } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' /** diff --git a/packages/self-modification/tool-cordis/tests/helpers.ts b/packages/self-modification/tool-cordis/tests/helpers.ts index b049c6c1fa..e608e4633c 100644 --- a/packages/self-modification/tool-cordis/tests/helpers.ts +++ b/packages/self-modification/tool-cordis/tests/helpers.ts @@ -1,5 +1,5 @@ -import { Context } from 'cordis' -import Timer from '@cordisjs/plugin-timer' +import { Context } from '@deepseek-ai/cordis' +import Timer from '@deepseek-ai/cordis-plugin-timer' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' diff --git a/packages/self-modification/tool-cordis/tests/inspect.spec.ts b/packages/self-modification/tool-cordis/tests/inspect.spec.ts index ca9fb4e03f..42415619e1 100644 --- a/packages/self-modification/tool-cordis/tests/inspect.spec.ts +++ b/packages/self-modification/tool-cordis/tests/inspect.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import type { Context, Fiber } from 'cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import { FiberState } from '../src/fiber-state.ts' import { describeApi, describeEvents, describePlugins, describeServices } from '../src/inspect.ts' import { call, LISTENER_CODE, setup, text } from './helpers.ts' diff --git a/packages/self-modification/tool-cordis/tests/integration.spec.ts b/packages/self-modification/tool-cordis/tests/integration.spec.ts index 488ab996b3..3697ea1b2b 100644 --- a/packages/self-modification/tool-cordis/tests/integration.spec.ts +++ b/packages/self-modification/tool-cordis/tests/integration.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' diff --git a/packages/self-modification/tool-cordis/tests/tool-cordis.spec.ts b/packages/self-modification/tool-cordis/tests/tool-cordis.spec.ts index 32846e35dd..030260d3c8 100644 --- a/packages/self-modification/tool-cordis/tests/tool-cordis.spec.ts +++ b/packages/self-modification/tool-cordis/tests/tool-cordis.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import Loader from '@cordisjs/plugin-loader' +import Loader from '@deepseek-ai/cordis-plugin-loader' import * as tool from '../src/index.ts' import { setup } from './helpers.ts' diff --git a/packages/self-modification/tool-cordis/tests/unmount-hmr.spec.ts b/packages/self-modification/tool-cordis/tests/unmount-hmr.spec.ts index 42c4c0196c..062da430e5 100644 --- a/packages/self-modification/tool-cordis/tests/unmount-hmr.spec.ts +++ b/packages/self-modification/tool-cordis/tests/unmount-hmr.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as tool from '../src/index.ts' diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 4813b76b10..156643a8bd 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session-query/session-query-sqlite" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-session-query": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-session-persistence": { @@ -37,15 +44,15 @@ } }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session-query/session-query-sqlite/src/index.ts b/packages/session-query/session-query-sqlite/src/index.ts index dc67665f77..473146168b 100644 --- a/packages/session-query/session-query-sqlite/src/index.ts +++ b/packages/session-query/session-query-sqlite/src/index.ts @@ -6,8 +6,8 @@ import { createHash, randomUUID } from 'node:crypto' import type { DatabaseSync } from 'node:sqlite' -import { Context, Service, type Fiber } from 'cordis' -import z from 'schemastery' +import { Context, Service, type Fiber } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type SessionPersistence from '@deepseek-ai/dsh-session-persistence' import type { @@ -65,7 +65,7 @@ export { /** Boot-context slot for a launcher-owned absolute path to this process's derived query index. */ export const SESSION_QUERY_SQLITE_PATH_KEY = 'launcherSessionQueryPath' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Launcher-owned absolute path to this process's disposable derived query index. */ launcherSessionQueryPath?: string diff --git a/packages/session-query/session-query-sqlite/src/invariant.ts b/packages/session-query/session-query-sqlite/src/invariant.ts index 011b121eaf..6d9761807a 100644 --- a/packages/session-query/session-query-sqlite/src/invariant.ts +++ b/packages/session-query/session-query-sqlite/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-query-sqlite' diff --git a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts index 01bfa60c43..aad427f10f 100644 --- a/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts +++ b/packages/session-query/session-query-sqlite/tests/load-path.e2e.ts @@ -6,8 +6,8 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' */ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import SessionStore from '@deepseek-ai/dsh-session' import SessionPersistenceSqlite from '@deepseek-ai/dsh-session-persistence-sqlite' diff --git a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts index 8427ebedc4..bbba91453d 100644 --- a/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts +++ b/packages/session-query/session-query-sqlite/tests/sqlite.spec.ts @@ -1,6 +1,6 @@ import { createAssistantMessage, createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context, type Fiber } from 'cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import { DatabaseSync } from 'node:sqlite' import { chmod, mkdtemp, rm, stat, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 3a52808f02..4d0e4c29de 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session-query/session-query" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,13 +32,13 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-title": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-session-persistence": { @@ -45,6 +52,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session-query/session-query/src/corpus.ts b/packages/session-query/session-query/src/corpus.ts index 8711597b69..717b2b5190 100644 --- a/packages/session-query/session-query/src/corpus.ts +++ b/packages/session-query/session-query/src/corpus.ts @@ -1,6 +1,6 @@ /** Live/persisted logical-corpus resolution for session-query. */ -import type { Context, Fiber } from 'cordis' +import type { Context, Fiber } from '@deepseek-ai/cordis' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceCorruptionError } from '@deepseek-ai/dsh-session-persistence' import type { SessionRecord } from './types.ts' diff --git a/packages/session-query/session-query/src/index.ts b/packages/session-query/session-query/src/index.ts index 919bf00c88..c9cdc62221 100644 --- a/packages/session-query/session-query/src/index.ts +++ b/packages/session-query/session-query/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-session-query */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { Session, snapshotSessionEvent, type SessionId } from '@deepseek-ai/dsh-session' import { foldSessionTitle } from '@deepseek-ai/dsh-session-title' import type { SessionTitleSnapshot } from '@deepseek-ai/dsh-session-title' @@ -65,7 +65,7 @@ export { } from './filters.ts' export { assertSessionHeadersCompatible } from './sources.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessionQuery: SessionQueryService } diff --git a/packages/session-query/session-query/src/invariant.ts b/packages/session-query/session-query/src/invariant.ts index d087dd2378..a264b4d279 100644 --- a/packages/session-query/session-query/src/invariant.ts +++ b/packages/session-query/session-query/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-query' diff --git a/packages/session-query/session-query/tests/search-helpers.spec.ts b/packages/session-query/session-query/tests/search-helpers.spec.ts index 1b139feacd..14422f4366 100644 --- a/packages/session-query/session-query/tests/search-helpers.spec.ts +++ b/packages/session-query/session-query/tests/search-helpers.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId , createMessage, createToolResultMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { SESSION_FORMAT_VERSION, diff --git a/packages/session-query/session-query/tests/session-query.spec.ts b/packages/session-query/session-query/tests/session-query.spec.ts index acc993d2b3..5a4228329b 100644 --- a/packages/session-query/session-query/tests/session-query.spec.ts +++ b/packages/session-query/session-query/tests/session-query.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it, vi } from 'vitest' -import { Context, type Fiber } from 'cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence, { SessionPersistenceCorruptionError, SessionPersistenceRevision } from '@deepseek-ai/dsh-session-persistence' diff --git a/packages/session-query/session-query/tests/tracing.spec.ts b/packages/session-query/session-query/tests/tracing.spec.ts index 24e8622ab2..8c9588be26 100644 --- a/packages/session-query/session-query/tests/tracing.spec.ts +++ b/packages/session-query/session-query/tests/tracing.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SESSION_FORMAT_VERSION, SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId as SessionIdType } from '@deepseek-ai/dsh-session' import SessionPersistence from '@deepseek-ai/dsh-session-persistence' diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 39b5ada943..6d8ef7696a 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session-query/tool-session-query" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-query": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-query": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -51,6 +58,6 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session-query/tool-session-query/src/index.ts b/packages/session-query/tool-session-query/src/index.ts index 5187e254ae..d204184cfe 100644 --- a/packages/session-query/tool-session-query/src/index.ts +++ b/packages/session-query/tool-session-query/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-tool-session-query */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { defineTool } from '@deepseek-ai/dsh-tools' import type {} from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/session-query/tool-session-query/src/invariant.ts b/packages/session-query/tool-session-query/src/invariant.ts index 73f0e35409..3c8fd74114 100644 --- a/packages/session-query/tool-session-query/src/invariant.ts +++ b/packages/session-query/tool-session-query/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-session-query' diff --git a/packages/session-query/tool-session-query/src/operations.ts b/packages/session-query/tool-session-query/src/operations.ts index f169842823..a8010c0e90 100644 --- a/packages/session-query/tool-session-query/src/operations.ts +++ b/packages/session-query/tool-session-query/src/operations.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-tool-session-query/operations */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { SessionId } from '@deepseek-ai/dsh-session' import { diff --git a/packages/session-query/tool-session-query/src/service-boundary.ts b/packages/session-query/tool-session-query/src/service-boundary.ts index 495897fddd..9fbfc0c55c 100644 --- a/packages/session-query/tool-session-query/src/service-boundary.ts +++ b/packages/session-query/tool-session-query/src/service-boundary.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-tool-session-query/service-boundary */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionQueryError, diff --git a/packages/session-query/tool-session-query/src/workspace-access.ts b/packages/session-query/tool-session-query/src/workspace-access.ts index faba3adf9f..a6c7b1ce40 100644 --- a/packages/session-query/tool-session-query/src/workspace-access.ts +++ b/packages/session-query/tool-session-query/src/workspace-access.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-tool-session-query/workspace-access */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import { SessionId, diff --git a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts index a00049eb3a..059e32faf1 100644 --- a/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts +++ b/packages/session-query/tool-session-query/tests/sqlite-integration.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts index 23ca3f939e..6741adc6eb 100644 --- a/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts +++ b/packages/session-query/tool-session-query/tests/tool-session-query.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context, type Fiber } from 'cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, CallId, HarnessError , createMessage } from '@deepseek-ai/dsh-llm' import { MAX_TIMER_DELAY_MS, TimeoutReason } from '@deepseek-ai/dsh-timeout' diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index afbc62345e..751b95d56a 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-checkpoint-policy" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -45,6 +52,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-checkpoint-policy/src/index.ts b/packages/session/session-checkpoint-policy/src/index.ts index 804ed0dcb1..0fc456eb08 100644 --- a/packages/session/session-checkpoint-policy/src/index.ts +++ b/packages/session/session-checkpoint-policy/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-session-checkpoint-policy */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session } from '@deepseek-ai/dsh-session' import type { StreamChunk } from '@deepseek-ai/dsh-llm' import { TOOL_ABORTED_BEFORE_DISPATCH, type ToolExecutionResult } from '@deepseek-ai/dsh-tools' diff --git a/packages/session/session-checkpoint-policy/src/invariant.ts b/packages/session/session-checkpoint-policy/src/invariant.ts index f6baece911..af12673ea1 100644 --- a/packages/session/session-checkpoint-policy/src/invariant.ts +++ b/packages/session/session-checkpoint-policy/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-checkpoint-policy' diff --git a/packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts b/packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts index b64ce563b9..5d782cee1d 100644 --- a/packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts +++ b/packages/session/session-checkpoint-policy/tests/crash-recovery.e2e.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { execa } from 'execa' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import SessionStore, { SessionId, TOOL_OUTCOME_UNKNOWN, diff --git a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts index a27da79e10..288dd21a26 100644 --- a/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts +++ b/packages/session/session-checkpoint-policy/tests/fixtures/crash-child.ts @@ -1,5 +1,5 @@ import { writeFile } from 'node:fs/promises' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' import { createUserMessage, CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts index dde59610c5..6501941c77 100644 --- a/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts +++ b/packages/session/session-checkpoint-policy/tests/session-checkpoint-policy.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { agentEvents, type Agent } from '@deepseek-ai/dsh-agent' import LlmService, { CallId, type GenerateOptions, LlmAdapter, type StreamChunk } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index b09211669b..aec127fa21 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-persistence-jsonl" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,19 +32,19 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "koffi": "^3.1.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-persistence-jsonl/src/format.ts b/packages/session/session-persistence-jsonl/src/format.ts index 809982f94d..2923b9e09b 100644 --- a/packages/session/session-persistence-jsonl/src/format.ts +++ b/packages/session/session-persistence-jsonl/src/format.ts @@ -9,8 +9,9 @@ */ import { join } from 'node:path' -import { decodeStorageRecord, packChunkRuns } from '@deepseek-ai/dsh-session' +import { decodeStorageRecord, packChunkRuns, SESSION_FORMAT_VERSION } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader, SessionId, StorageRecord } from '@deepseek-ai/dsh-session' +import { SessionFormatUnsupportedError, sessionFormatVersionRefusal } from '@deepseek-ai/dsh-session-persistence' /** Physical encoding selected for JSONL session artifacts. */ export type JsonlCompression = 'zstd' | 'none' @@ -229,6 +230,22 @@ interface SessionLogScan { } /** Parse one complete header record supplied independently from event rows. */ +/** + * Refuse a header carrying a format version this build does not read BEFORE + * validating the current header shape or decoding any event row: a future + * format need not satisfy today's structural checks at all, and its user must + * see "upgrade the harness", never "corrupt session log". + * @param parsed - the JSON-parsed first line of a session artifact. + */ +function refuseForeignFormatVersion(parsed: unknown): void { + if (typeof parsed !== 'object' || parsed === null) return + const { version, id } = parsed as { version?: unknown; id?: unknown } + if (typeof version !== 'number' || version === SESSION_FORMAT_VERSION) return + throw new SessionFormatUnsupportedError( + sessionFormatVersionRefusal(typeof id === 'string' ? id : String(id), version), + ) +} + function parseHeaderRecord(record: Buffer): SessionHeader { if (record.length === 0 || record.at(-1) !== 0x0A || record.indexOf(0x0A) !== record.length - 1) { throw new Error('empty or header-less session log') @@ -239,6 +256,7 @@ function parseHeaderRecord(record: Buffer): SessionHeader { } catch { throw new Error('corrupt session log: header line is not valid JSON') } + refuseForeignFormatVersion(parsed) if (!isHeaderLine(parsed)) { throw new Error('corrupt session log: first line is not a session header') } diff --git a/packages/session/session-persistence-jsonl/src/index.ts b/packages/session/session-persistence-jsonl/src/index.ts index 8d4aad8e7a..42a3c431ce 100644 --- a/packages/session/session-persistence-jsonl/src/index.ts +++ b/packages/session/session-persistence-jsonl/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-session-persistence-jsonl */ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { readdirSync } from 'node:fs' import { open, mkdir, readFile, readdir, realpath, link, rm, stat, truncate } from 'node:fs/promises' import { dirname, join, resolve } from 'node:path' @@ -16,9 +16,10 @@ import { scheduler } from 'node:timers/promises' import { randomBytes } from 'node:crypto' import { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, - SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, + SessionPersistence, SessionPersistenceRevision, PersistenceCoordinator, SessionFormatUnsupportedError, type PersistenceBackend, type SessionLocation, type SessionPersistenceSnapshot, - type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type StoredPrefix, + type SessionInspection, type SessionPersistenceRevision as PersistenceRevision, type SessionRawArtifact, + type StoredPrefix, } from '@deepseek-ai/dsh-session-persistence' import type { SessionEvent, SessionId, SessionHeader, SessionPreparation } from '@deepseek-ai/dsh-session' import { @@ -233,6 +234,73 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi } } + /** + * Read a session's stored artifact text verbatim: the durable file bytes + * decoded from this backend's physical encoding (complete zstd frames + * concatenated, or UTF-8 plaintext). The content is the exact JSONL text the + * backend wrote — never a reconstruction from parsed events — so packed- + * chunk rows, key order, and line breaks survive byte-for-byte. A torn + * final frame is omitted, matching the committed-prefix semantics of every + * other read. + * @param id - the persisted session to read. + * @param signal - optional cancellation for the stat/read/decode work. + * @returns the raw artifact text plus the header parsed from its own first + * line, or `undefined` when the session has no stored artifact. + */ + override async readRaw(id: SessionId, signal?: AbortSignal): Promise { + signal?.throwIfAborted() + await this.ensureRootEncoding() + signal?.throwIfAborted() + const path = await this.findLog(id, signal) + if (path === undefined) return undefined + const { buffer } = await this.readStableFile(path, signal) + let content: string + if (this.compression === 'zstd') { + const { frames } = scanZstdFrames(buffer) + if (frames.length === 0) return undefined + const decoder = createZstdFrameDecoder() + const plaintexts: Buffer[] = [] + // The decoder yields views into a reused buffer; copy each frame's + // plaintext immediately so a later concat cannot read overwritten memory. + for (const plaintext of decoder.decode(buffer, frames)) { + signal?.throwIfAborted() + plaintexts.push(Buffer.from(plaintext)) + } + content = Buffer.concat(plaintexts).toString('utf8') + } else { + content = buffer.toString('utf8') + } + const meta = parseHeaderMeta(content.split('\n', 1)[0] as string) + if (meta === undefined || meta.id !== id) { + throw new Error(`corrupt session log: invalid header line in "${path}"`) + } + // The logical artifact name is `session.jsonl` regardless of the physical + // encoding suffix (`.jsonl.zstd` marks compression only). + return { meta, filename: 'session.jsonl', content } + } + + /** + * Read a file's bytes under a revision-stable loop: a writer appending + * between stat and readFile would yield a torn physical file, so retry + * while the stat revision changes. + * @param path - the artifact file to read. + * @param signal - optional cancellation for the stat/read work. + * @returns the stable bytes and the revision that matched both stats. + */ + private async readStableFile( + path: string, + signal?: AbortSignal, + ): Promise<{ buffer: Buffer; revision: PersistenceRevision }> { + for (;;) { + signal?.throwIfAborted() + const before = fileRevision(await stat(path, { bigint: true })) + const buffer = await readFile(path, { signal }) + signal?.throwIfAborted() + const after = fileRevision(await stat(path, { bigint: true })) + if (before === after) return { buffer, revision: after } + } + } + /** * Read a stored prefix and convert torn-tail state to the opaque marker the * coordinator can round-trip without knowing the physical encoding. @@ -242,33 +310,31 @@ export class SessionPersistenceJsonl extends SessionPersistence implements Persi expectedId?: SessionId, signal?: AbortSignal, ): Promise> { - let buffer: Buffer - let revision: PersistenceRevision - for (;;) { - signal?.throwIfAborted() - const before = fileRevision(await stat(path, { bigint: true })) - buffer = await readFile(path, { signal }) - signal?.throwIfAborted() - const after = fileRevision(await stat(path, { bigint: true })) - if (before === after) { - revision = after - break - } - } + const { buffer, revision } = await this.readStableFile(path, signal) let prefix: Omit, 'revision'> - if (this.compression === 'zstd') { - prefix = await this.readZstdPrefix(buffer, signal) - } else { - signal?.throwIfAborted() - const { meta, events, committedBytes } = scanLog(buffer) - signal?.throwIfAborted() - prefix = { - meta, - events, - ...committedBytes < buffer.byteLength - ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } - : {}, + try { + if (this.compression === 'zstd') { + prefix = await this.readZstdPrefix(buffer, signal) + } else { + signal?.throwIfAborted() + const { meta, events, committedBytes } = scanLog(buffer) + signal?.throwIfAborted() + prefix = { + meta, + events, + ...committedBytes < buffer.byteLength + ? { tornMarker: { truncateTo: committedBytes, recoveredEvents: [] } } + : {}, + } } + } catch (error: unknown) { + // A parse-time format refusal predates any SessionHeader, so the + // coordinator's locate-based enrichment cannot run; attach the artifact + // this read actually refused. + if (error instanceof SessionFormatUnsupportedError && error.location === undefined) { + throw new SessionFormatUnsupportedError(`${error.message} (raw log: ${path})`, { kind: 'jsonl', path }) + } + throw error } signal?.throwIfAborted() await this.assertStoredIdentity(path, prefix.meta, expectedId, signal) diff --git a/packages/session/session-persistence-jsonl/src/invariant.ts b/packages/session/session-persistence-jsonl/src/invariant.ts index 94d7c2b494..c48a083e8f 100644 --- a/packages/session/session-persistence-jsonl/src/invariant.ts +++ b/packages/session/session-persistence-jsonl/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-jsonl' diff --git a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts index c7b4ab8841..e980006e07 100644 --- a/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/jsonl.spec.ts @@ -1,9 +1,9 @@ import { MessageId, createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { appendFile, mkdtemp, mkdir, rm, readFile, writeFile, readdir, stat, symlink } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { isAbsolute, join, relative, resolve } from 'node:path' +import { dirname, isAbsolute, join, relative, resolve } from 'node:path' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import SessionPersistenceJsonl from '@deepseek-ai/dsh-session-persistence-jsonl' @@ -186,6 +186,76 @@ describe('SessionPersistenceJsonl: format helpers', () => { }) await fiber.dispose() }) + + it('refuses a structurally foreign future header as unsupported, not corrupt', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' }) + // A future format need not satisfy today's header shape at all (no + // createdAt, unknown fields): the version must be refused before shape + // validation, so the user sees the upgrade direction. + const id = SessionId('future-shape') + const path = rawLogPath(resolve(absoluteRoot), '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id, futureOnly: true })}\n{"future":"row"}\n`) + const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/) + expect(failure?.message).toContain(`(raw log: ${path})`) + await fiber.dispose() + }) + + it('keeps a non-object header line a corruption, not a format refusal', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' }) + // Valid JSON that is no object carries no version to compare, so the + // version guard must pass it through to the corruption diagnostics. + const id = SessionId('scalar-header') + const path = rawLogPath(resolve(absoluteRoot), '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, '42\n') + const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).not.toBe('SessionFormatUnsupportedError') + expect(failure?.message).toContain('first line is not a session header') + await fiber.dispose() + }) + + it('names a foreign-version header by its stringified non-string id', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' }) + // A future header's id field is as untrusted as the rest of its shape: + // the refusal must still name the session it read, not crash on the type. + const id = SessionId('numeric-id') + const path = rawLogPath(resolve(absoluteRoot), '/work', id) + await mkdir(dirname(path), { recursive: true }) + await writeFile(path, `${JSON.stringify({ type: 'session', version: 42, id: 123 })}\n`) + const failure = await ctx.sessionPersistence.load(id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toContain('session "123" uses log format v42') + await fiber.dispose() + }) + + it('points a format refusal at the raw log path', async () => { + const absoluteRoot = await freshRoot() + const ctx = new Context() + await ctx.plugin(SessionStore) + const fiber = await ctx.plugin(SessionPersistenceJsonl, { root: absoluteRoot, compression: 'none' }) + const m = { ...meta('newer-format', '/work'), version: 7 } + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, [ + { type: 'turn/start', seq: 0, time: 1, data: { turn: 1 } }, + { type: 'turn/end', seq: 1, time: 2, data: { turn: 1, reason: { kind: 'completed' } } }, + ]) + const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toContain(`(raw log: ${rawLogPath(resolve(absoluteRoot), '/work', m.id)})`) + await fiber.dispose() + }) }) describe('SessionPersistenceJsonl: durability and crash semantics', () => { @@ -217,6 +287,46 @@ describe('SessionPersistenceJsonl: durability and crash semantics', () => { expect((await ctx.sessionPersistence.list()).map(h => h.id)).toContain(m.id) }) + it('readRaw returns the stored artifact text verbatim with its original filename', async () => { + const m = meta('raw-read', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const raw = await ctx.sessionPersistence.readRaw(m.id) + expect(raw).toBeDefined() + expect(raw!.filename).toBe('session.jsonl') + expect(raw!.meta.id).toBe(m.id) + // Byte-identical to the physical file — never a reconstruction. + expect(raw!.content).toBe(await readFile(rawLogPath(root, '/work', m.id), 'utf8')) + expect(raw!.content.split('\n')[0]).toBe(JSON.stringify(toHeaderLine(m))) + const scanned = scanLog(Buffer.from(raw!.content)) + expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + }) + + it('readRaw is undefined for an absent session', async () => { + const m = meta('raw-missing', '/work') + expect(await ctx.sessionPersistence.readRaw(m.id)).toBeUndefined() + }) + + it('readRaw rejects a corrupt header line instead of exporting it', async () => { + const m = meta('raw-corrupt', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + await writeFile(rawLogPath(root, '/work', m.id), 'not a header line\n{"type":"turn/start","seq":0}\n') + await expect(ctx.sessionPersistence.readRaw(m.id)).rejects.toThrow(/corrupt session log/) + }) + + it('readRaw retries when the file revision changes during the read', async () => { + const m = meta('raw-revision-race', '/work') + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + statRace.path = rawLogPath(root, '/work', m.id) + + const raw = await ctx.sessionPersistence.readRaw(m.id) + expect(raw).toBeDefined() + // Two stat calls per iteration; the mocked revision change forces a retry. + expect(statRace.reads).toBe(4) + }) + it('keeps the same location on resume and gives a fork its own location', async () => { const parent = meta('location-parent', '/work') const parentLocation = ctx.sessionPersistence.locate(parent) diff --git a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts index b459fcac43..49d1182b44 100644 --- a/packages/session/session-persistence-jsonl/tests/zstd.spec.ts +++ b/packages/session/session-persistence-jsonl/tests/zstd.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { appendFile, mkdir, mkdtemp, open, readFile, readdir, rm, stat, writeFile } from 'node:fs/promises' import type { FileHandle } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -356,6 +356,39 @@ describe('SessionPersistenceJsonl: default Zstandard encoding', () => { expect((await ctx.sessionPersistence.load(header.id)).events).toEqual(oneTurnLog()) }) + it('readRaw decodes the compressed artifact back to the original JSONL text', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('raw-read-zstd', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + + const raw = await ctx.sessionPersistence.readRaw(header.id) + expect(raw).toBeDefined() + // The logical name drops the physical encoding suffix. + expect(raw!.filename).toBe('session.jsonl') + expect(raw!.meta.id).toBe(header.id) + expect(raw!.content).toBe([ + JSON.stringify(toHeaderLine(header)), + ...oneTurnLog().map(e => JSON.stringify(e)), + '', + ].join('\n')) + const scanned = scanLog(Buffer.from(raw!.content)) + expect(scanned.events.map(event => event.type)).toEqual(oneTurnLog().map(event => event.type)) + }) + + it('readRaw is undefined for a zstd artifact that carries no frame', async () => { + const root = await freshRoot() + const ctx = await mount(root) + const header = meta('raw-zero-frame', '/work') + await ctx.sessionPersistence.create(header) + await ctx.sessionPersistence.append(header.id, oneTurnLog()) + // Overwrite the physical artifact with a short buffer: frame scanning + // answers zero frames before any magic check, so readRaw reports no artifact. + await writeFile(logPath(root, '/work', header.id, 'zstd'), Buffer.alloc(0)) + expect(await ctx.sessionPersistence.readRaw(header.id)).toBeUndefined() + }) + it('resolves the default when a programmatic wrapper bypasses Loader schema normalization', async () => { const root = await freshRoot() const ctx = new Context() diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json index 90415bd8cc..60858d5aa8 100644 --- a/packages/session/session-persistence-sqlite/package.json +++ b/packages/session/session-persistence-sqlite/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-persistence-sqlite", "description": "SQLite durable session persistence backend for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-persistence-sqlite" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,18 +32,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-persistence-sqlite/src/index.ts b/packages/session/session-persistence-sqlite/src/index.ts index b26e273cf1..15cf869b69 100644 --- a/packages/session/session-persistence-sqlite/src/index.ts +++ b/packages/session/session-persistence-sqlite/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-session-persistence-sqlite */ -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { randomUUID } from 'node:crypto' import { statSync } from 'node:fs' import { DatabaseSync } from 'node:sqlite' @@ -28,15 +28,18 @@ import { export { SCHEMA_VERSION } from './schema.ts' /** - * Serialize an event's surface-metadata fields for SQL binding. Both fields are - * nullable TEXT columns — null when the event has no surface metadata (non-surface - * events, events written before surface support). + * Serialize an event's optional envelope fields for SQL binding. The surface + * fields are nullable TEXT columns — null when the event has no surface + * metadata (non-surface events, events written before surface support); the + * ignorable marker is a nullable INTEGER column — `1` iff the envelope carries + * `ignorable: true`. */ -function surfaceBindings(event: SessionEvent): [string | null, string | null] { +function envelopeBindings(event: SessionEvent): [string | null, string | null, number | null] { const se = event as SessionEvent return [ se.sourceEventSeqs ? JSON.stringify(se.sourceEventSeqs) : null, se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null, + event.ignorable === true ? 1 : null, ] } @@ -225,7 +228,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers if (row === undefined) return undefined const meta = rowToMeta(row) const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') + .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? AND seq >= ? ORDER BY seq') .all(id, fromSeq) as unknown as EventRow[] signal?.throwIfAborted() const { preserved } = scanRows(eventRows, fromSeq) @@ -247,7 +250,7 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers const row = this.rowFor(id) if (row !== undefined) { const eventRows = this.db - .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op FROM events WHERE session_id = ? ORDER BY seq') + .prepare('SELECT seq, type, time, data, source_event_seqs, surface_op, ignorable FROM events WHERE session_id = ? ORDER BY seq') .all(id) as unknown as EventRow[] snapshot = { row, eventRows } } @@ -279,14 +282,14 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers async appendBatch(meta: SessionHeader, events: readonly SessionEvent[], isMaterialized: boolean): Promise { await this.ready const insertEvent = this.db.prepare( - 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', ) this.db.exec('BEGIN') try { if (!isMaterialized) this.writeRow(meta) for (const event of events) { - const [surfaceSeqs, surfaceOp] = surfaceBindings(event) - insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp) + const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event) + insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable) } this.db.prepare('UPDATE sessions SET revision = revision + 1 WHERE id = ?').run(meta.id) this.db.exec('COMMIT') @@ -310,11 +313,11 @@ export class SessionPersistenceSqlite extends SessionPersistence implements Pers } if (closers.length > 0) { const insertEvent = this.db.prepare( - 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op) VALUES (?, ?, ?, ?, ?, ?, ?)', + 'INSERT INTO events (session_id, seq, type, time, data, source_event_seqs, surface_op, ignorable) VALUES (?, ?, ?, ?, ?, ?, ?, ?)', ) for (const event of closers) { - const [surfaceSeqs, surfaceOp] = surfaceBindings(event) - insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp) + const [surfaceSeqs, surfaceOp, ignorable] = envelopeBindings(event) + insertEvent.run(meta.id, event.seq, event.type, event.time, JSON.stringify(event.data), surfaceSeqs, surfaceOp, ignorable) } } if (tornMarker !== undefined || closers.length > 0) { diff --git a/packages/session/session-persistence-sqlite/src/invariant.ts b/packages/session/session-persistence-sqlite/src/invariant.ts index 9d841a053d..7a5e905e30 100644 --- a/packages/session/session-persistence-sqlite/src/invariant.ts +++ b/packages/session/session-persistence-sqlite/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence-sqlite' diff --git a/packages/session/session-persistence-sqlite/src/schema.ts b/packages/session/session-persistence-sqlite/src/schema.ts index c7a4de7233..c7402d7d4b 100644 --- a/packages/session/session-persistence-sqlite/src/schema.ts +++ b/packages/session/session-persistence-sqlite/src/schema.ts @@ -17,7 +17,7 @@ import type { SessionEvent, SessionId, SessionHeader, SurfaceOp } from '@deepsee * layout; orthogonal to a session's own `version` (which versions the EVENT * vocabulary, stored per session in the `sessions` row). */ -export const SCHEMA_VERSION = 14 +export const SCHEMA_VERSION = 15 /** SQLite application id protecting unrelated databases from persistence writes. */ export const SESSION_PERSISTENCE_SQLITE_APPLICATION_ID = 0x44534850 @@ -55,6 +55,8 @@ export interface EventRow { source_event_seqs: string | null /** JSON-encoded `SurfaceOp` — how the event entered the surface, or null. */ surface_op: string | null + /** `1` iff the event carries the envelope's `ignorable: true` marker, else null. */ + ignorable: number | null } /** @@ -139,6 +141,7 @@ function configureDatabase(db: DatabaseSync, path: string, journalMode: JournalM data TEXT NOT NULL, source_event_seqs TEXT, surface_op TEXT, + ignorable INTEGER, PRIMARY KEY (session_id, seq) ) STRICT `) @@ -203,12 +206,14 @@ export function rowToEvent(row: EventRow): SessionEvent { ...row.source_event_seqs !== null ? { sourceEventSeqs: JSON.parse(row.source_event_seqs) as number[] } : {}, ...row.surface_op !== null ? { surfaceOp: JSON.parse(row.surface_op) as SurfaceOp } : {}, } + const ignorableField = row.ignorable === 1 ? { ignorable: true as const } : {} return { type: row.type as SessionEvent['type'], seq: row.seq, time: row.time, data: JSON.parse(row.data) as SessionEvent['data'], ...surfaceFields, + ...ignorableField, } as SessionEvent } diff --git a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts index afaa060490..bec3a11dad 100644 --- a/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts +++ b/packages/session/session-persistence-sqlite/tests/sqlite.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage, createMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { existsSync } from 'node:fs' import { chmod, mkdtemp, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' @@ -92,6 +92,7 @@ describe('scanRows', () => { seq: e.seq, type: e.type, time: e.time, data: JSON.stringify(e.data), source_event_seqs: se.sourceEventSeqs !== undefined ? JSON.stringify(se.sourceEventSeqs) : null, surface_op: se.surfaceOp !== undefined ? JSON.stringify(se.surfaceOp) : null, + ignorable: e.ignorable === true ? 1 : null, } }) @@ -142,8 +143,8 @@ describe('scanRows', () => { it('throws on an unparsable row inside the committed region', () => { const withCorruptCommitted: EventRow[] = [ - { seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null }, // corrupt, sits before a turn/end - { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null }, + { seq: 0, type: 'turn/start', time: 1, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // corrupt, sits before a turn/end + { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), source_event_seqs: null, surface_op: null, ignorable: null }, ] expect(() => scanRows(withCorruptCommitted)).toThrow(/unparsable committed event/) }) @@ -151,7 +152,7 @@ describe('scanRows', () => { it('tolerates an unparsable torn-tail row after the last turn/end', () => { const withCorruptTail: EventRow[] = [ ...rows(oneTurnLog()), - { seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null }, // torn fragment, no committed turn/end after + { seq: 6, type: 'turn/start', time: 7, data: '{not json', source_event_seqs: null, surface_op: null, ignorable: null }, // torn fragment, no committed turn/end after ] const { preserved, tornFrom } = scanRows(withCorruptTail) expect(preserved).toEqual(oneTurnLog()) @@ -658,7 +659,7 @@ describe('SessionPersistenceSqlite: durability and crash semantics', () => { }) it('exposes the schema version constant', () => { - expect(SCHEMA_VERSION).toBe(14) + expect(SCHEMA_VERSION).toBe(15) }) it('keeps the revision stable for an empty repair hook', async () => { @@ -857,6 +858,7 @@ describe('surface field round-trip', () => { data: JSON.stringify({ turn: 1, step: 1, content: [] }), source_event_seqs: JSON.stringify([3, 5]), surface_op: JSON.stringify('append'), + ignorable: null, } const event = rowToEvent(row) expect((event as SurfaceEvent).sourceEventSeqs).toEqual([3, 5]) @@ -869,6 +871,7 @@ describe('surface field round-trip', () => { data: JSON.stringify({ turn: 1, step: 1, content: [] }), source_event_seqs: JSON.stringify([0, 1]), surface_op: JSON.stringify({ op: 'replace', start: 0, end: 1 }), + ignorable: null, } const event = rowToEvent(row) expect((event as SurfaceEvent).sourceEventSeqs).toEqual([0, 1]) @@ -879,10 +882,10 @@ describe('surface field round-trip', () => { const rows: EventRow[] = [ { seq: 0, type: 'user/message', time: 1, data: JSON.stringify({ content: [{ type: 'text', text: 'hi' }], source: { kind: 'user' } }), - source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}' }, + source_event_seqs: null, surface_op: '{"op":"replace","start":0,"end":0}', ignorable: null }, { seq: 1, type: 'turn/end', time: 2, data: JSON.stringify({ turn: 1, reason: { kind: 'completed' } }), - source_event_seqs: null, surface_op: null }, + source_event_seqs: null, surface_op: null, ignorable: 1 }, ] const { preserved } = scanRows(rows) expect(preserved).toHaveLength(2) diff --git a/packages/session/session-persistence/README.i18n.yaml b/packages/session/session-persistence/README.i18n.yaml index 15808bb5e4..eed71ad212 100644 --- a/packages/session/session-persistence/README.i18n.yaml +++ b/packages/session/session-persistence/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/session/session-persistence/README.md -README.md: 391548b1b896dca14cbe4f4ae55cf4180c4e0ac2 -README.zh.md: 7213e1ee71ba418ffacc3685df371dcba33588a7 +README.md: 324c00b3202bd136566137e1bd398b29d2ea4b82 +README.zh.md: 2ef5e9a90f0323f8edf8fdc4f936c41ca7e08c70 diff --git a/packages/session/session-persistence/README.md b/packages/session/session-persistence/README.md index 391548b1b8..324c00b320 100644 --- a/packages/session/session-persistence/README.md +++ b/packages/session/session-persistence/README.md @@ -14,9 +14,9 @@ The persisted unit IS the existing `SessionEvent` (event-sourced model — the l | `create(meta): Promise` | Register a new session's metadata. MAY defer the physical write until the first `append` (lazy materialization). | | `append(id, events): Promise` | Durably persist a batch. Append-only; first event `seq` == stored next-seq after any repair; rejects non-JSON-serializable data naming the offending type. | | `prepare(id, signal?): Promise` | Reserve the exact unpublished Session used by resume. A coordinator reuses an earlier inspection when available, commits pending recovery, and releases an unpublished reservation back to its bounded cache on disposal. | -| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption, malformed records, and unknown `version` reject. | +| `load(id): Promise<{ meta; events }>` | Return an immutable balanced logical log after converting supported older records from the same format version and committing cold recovery. A live load first flushes its snapshot and rejects while its turn is open; a cold load preserves an interrupted final turn and durably closes it with synthetic `tool/result`/`step/end?`/`turn/end {interrupted}` events. Only a torn tail fragment is dropped; committed corruption and malformed records reject as `SessionPersistenceCorruptionError`, while an unsupported format `version` or an event type unknown to this build (without the envelope's `ignorable` marker) refuses as `SessionFormatUnsupportedError`, naming the refusal direction and the raw log path when the backend keeps one artifact per session. | | `inspect(id, signal?): Promise<{ meta; events }>` | Return an upgraded, validated, deeply frozen logical view without committing recovery or publishing a Session. A cold view receives in-memory synthetic recovery closers while its physical torn tail remains untouched; an already-live view is its current immutable snapshot and may contain an open turn. Coordinator-backed implementations retain the exact cold unpublished Session in a bounded LRU for later `prepare`, but discard and reload it when the stored revision changes. Same-id inspections share an in-flight read. | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Intended for checkpoint consumers that apply only events after a stored sequence number. | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | Return valid stored events with `seq >= fromSeq` without preparation caching, truncation, closers, or coordinator state. A `fromSeq` at or past the stored end returns an empty event list; a negative or non-safe-integer `fromSeq` rejects. Seek-capable backends (SQLite) read only the suffix unless converting a supported older record requires earlier records; sequential backends (JSONL) parse the whole artifact and skip forward. Unknown-type refusal follows that access pattern: a seek read checks only the returned suffix, while the sequential fallback also refuses on an unknown required event below the window. Intended for checkpoint consumers that apply only events after a stored sequence number. | | `list(signal?): Promise` | Lightweight listing from metadata, no full-log parse. The optional signal cancels backend listing work. A zero-event lazily-materialized session is absent from `list`. | | `listSnapshots(signal?): Promise` | Lightweight metadata plus an opaque branded per-log revision, without loading event logs. A revision stays equal while that log and its backing store are unchanged, changes after append or mutating load repair, and cannot collide solely because two stores use the same local counter. The optional signal requests cancellation of backend discovery work; first-party backends settle any started listing work before rejecting so an awaited call is quiescent. | diff --git a/packages/session/session-persistence/README.zh.md b/packages/session/session-persistence/README.zh.md index 7213e1ee71..2ef5e9a90f 100644 --- a/packages/session/session-persistence/README.zh.md +++ b/packages/session/session-persistence/README.zh.md @@ -14,9 +14,9 @@ | `create(meta): Promise` | 注册新会话元数据。可以将物理写入延迟到第一次 `append`(延迟实体化)。 | | `append(id, events): Promise` | 持久保存一个批次。仅追加;任何修复后,第一个事件 `seq` == 已存储 next-seq;非 JSON 可序列化数据会被拒绝,并命名违规类型。 | | `prepare(id, signal?): Promise` | 预留恢复所使用的那个未发布 Session。协调器会尽可能复用之前的检查结果、提交待处理恢复,并在 dispose 时将未发布 reservation 释放回有界缓存。 | -| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏、格式错误的记录和未知 `version` 会被拒绝。 | +| `load(id): Promise<{ meta; events }>` | 转换同一格式版本中受支持的旧记录后,返回不可变、平衡的逻辑日志,并提交冷恢复。实时 load 先 flush 其快照,并在轮次开放时拒绝;冷 load 保留中断的最终轮次,并用合成 `tool/result`/`step/end?`/`turn/end {interrupted}` 事件持久关闭它。只丢弃撕裂尾部碎片;已提交损坏和格式错误的记录以 `SessionPersistenceCorruptionError` 拒绝,不支持的格式 `version` 或本构建不认识且信封未带 `ignorable` 标记的事件类型以 `SessionFormatUnsupportedError` 拒绝,消息说明拒绝方向,并在后端为每个会话保留独立文件时给出原始日志路径。 | | `inspect(id, signal?): Promise<{ meta; events }>` | 返回已经升级、验证和深度冻结的逻辑视图,但不提交恢复或发布 Session。冷视图会获得仅存在于内存的合成恢复 closer,物理撕裂尾部保持不变;实时状态下的视图则是当前不可变快照,可能包含开放的轮次。基于协调器的实现会在有界 LRU 中保留该冷状态下未发布的 Session 本身,供后续 `prepare` 使用,但已存储修订值变化后会丢弃并重新读取。同 id 检查共享进行中的读取。 | -| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。供 checkpoint 消费方只应用已存序号之后的事件。 | +| `readFrom(id, fromSeq, signal?): Promise<{ meta; events }>` | 返回 `seq >= fromSeq` 的有效已存储事件,不进入 preparation 缓存、不截断、不合成 closer,也不发布协调器状态。`fromSeq` 达到或超过已存储末尾时返回空事件列表;负数或非安全整数 `fromSeq` 会被拒绝。可寻址后端(SQLite)只读后缀,除非转换受支持的旧记录需要读取更早的记录;顺序后端(JSONL)解析整个产物并向前跳过。未知类型拒绝遵循同一读取方式:寻址读取只检查返回的后缀,顺序回退路径还会拒绝窗口以下的未知必需事件。供 checkpoint 消费方只应用已存序号之后的事件。 | | `list(signal?): Promise` | 从元数据轻量列出,不解析完整日志。可选信号取消后端列表工作。零事件延迟实体化会话不在 `list` 中。 | | `listSnapshots(signal?): Promise` | 返回轻量元数据和每份日志一个不透明、带品牌类型的修订值,不加载事件日志。日志及其后端存储不变时,修订保持相等;append 或变更性 load 修复后会改变;不会仅因两个存储使用相同本地计数器而冲突。可选信号请求取消后端发现工作;第一方后端会先等待所有已启动的列出工作结束,再予以拒绝,因此调用返回拒绝时,相关工作已完全停稳。 | diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 10517be615..33c900a3d8 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-persistence" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -37,6 +44,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-persistence/src/coordinator.ts b/packages/session/session-persistence/src/coordinator.ts index ec4fb72aeb..eeeb8a5778 100644 --- a/packages/session/session-persistence/src/coordinator.ts +++ b/packages/session/session-persistence/src/coordinator.ts @@ -5,10 +5,11 @@ * @module @deepseek-ai/dsh-session-persistence/coordinator */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { adoptSessionEvent, interruptedTurnClosers, + KNOWN_SESSION_EVENT_TYPES, SESSION_FORMAT_VERSION, SessionPreparation, snapshotJsonValue, @@ -16,7 +17,7 @@ import { } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' -import type { SessionInspection } from './index.ts' +import type { SessionInspection, SessionLocation } from './index.ts' import type { SessionPersistenceRevision } from './revision.ts' import { observeQueuedAbort, SessionPreparations } from './preparations.ts' import type { SessionPreparationReservation } from './preparations.ts' @@ -43,6 +44,42 @@ export class SessionPersistenceCorruptionError extends Error { } } +/** + * The stored log is intact but this runtime cannot faithfully interpret it: + * the header carries an unsupported format version, or an event's type is + * unknown to this build and the event is not marked ignorable. Distinct from + * {@link SessionPersistenceCorruptionError} — nothing is damaged; the raw log + * remains readable at {@link location} when the backend keeps one artifact + * per session. + */ +export class SessionFormatUnsupportedError extends Error { + /** + * @param message - stable reason the log cannot be interpreted, already + * including the raw-log path when one exists. + * @param location - the backend's artifact location, when one exists. + */ + constructor(message: string, readonly location?: SessionLocation) { + super(message) + this.name = 'SessionFormatUnsupportedError' + } +} + +/** + * Direction-aware refusal text for a stored session whose format version this + * build does not read. Shared by the coordinator's load-time check and by + * backends that must refuse BEFORE decoding version-dependent structure (a + * future format may not satisfy today's structural checks at all, and the + * user must see "upgrade the harness", never "corrupt"). + * @param id - the stored session id, for message context. + * @param version - the stored format version. + * @returns the stable refusal text, without a raw-log path suffix. + */ +export function sessionFormatVersionRefusal(id: string, version: number): string { + return version > SESSION_FORMAT_VERSION + ? `session "${id}" uses log format v${version}, but this harness reads only v${SESSION_FORMAT_VERSION}: the log was written by a newer harness — upgrade the harness to open it` + : `session "${id}" uses log format v${version}, older than the supported v${SESSION_FORMAT_VERSION}, and this build ships no upgrade path for it` +} + /** Coordinator policy supplied by a concrete persistence backend. */ export interface PersistenceCoordinatorOptions { /** Maximum completed unpublished preparations retained for reuse. */ @@ -126,6 +163,11 @@ export interface PersistenceBackend { * contains a supported legacy shape whose normalization needs earlier * message-identity facts, in which case the coordinator falls back * to the complete stored prefix. + * Unknown-type refusal follows the same suffix scope: a seek-capable + * backend's `readFrom` checks only the returned suffix, while the + * sequential fallback parses the whole artifact and refuses on an unknown + * required event anywhere in it — over-refusal on the sequential side is + * accepted rather than widening the seek read. * @param id - persisted session id to resolve. * @param fromSeq - first event seq to include (non-negative safe integer, * validated by the coordinator before this hook runs). @@ -156,6 +198,14 @@ export interface PersistenceBackend { */ list(signal?: AbortSignal): Promise + /** + * Optional side-effect-free artifact locator, used to point refusal + * diagnostics ({@link SessionFormatUnsupportedError}) at the raw log. + * Backends without one artifact per session omit it or return `undefined`. + * @param meta - the header whose artifact is requested. + */ + locate?(meta: SessionHeader): SessionLocation | undefined + /** * Optional lifecycle teardown (e.g. close a database handle). Awaited by the * coordinator's dispose effect AFTER the quiescence drain. A stateless file @@ -631,9 +681,13 @@ export class PersistenceCoordinator { private async appendCore(id: SessionId, events: readonly SessionEvent[]): Promise { // Every append route converges here: the public service, live write-behind - // drains, and HMR seed/suffix adoption. Keep vocabulary rejection at that - // shared boundary so a stale JavaScript plugin cannot persist an event that - // this same backend will refuse to load. + // drains, and HMR seed/suffix adoption. Legacy-shape rejection stays at + // this shared boundary so a stale JavaScript plugin cannot persist a + // retired shape this backend refuses to load. The unknown-type guard is + // deliberately read-side only: an append-time refusal would stall a live + // session's durability mid-flight, which costs more than a loud refusal at + // the log's next load (trade-off owned by the session-log-version-mechanism + // Agent Note). assertSupportedEvents(events, id) if (events.length === 0) return this.preparations.assertWritable(id) @@ -806,7 +860,9 @@ export class PersistenceCoordinator { const whole = await this.readStoredPrefix(id, signal) return { meta: whole.meta, events: whole.events.filter(event => event.seq >= fromSeq) } } - return { meta: structuredClone(suffix.meta), events: snapshotStoredEvents(suffix.events, id) } + const events = snapshotStoredEvents(suffix.events, id) + this.assertEventsSupported(suffix.meta, events) + return { meta: structuredClone(suffix.meta), events } } const whole = await this.readStoredPrefix(id, signal) // Sequential fallback: contiguous seqs from 0 make the suffix an index slice. @@ -824,9 +880,11 @@ export class PersistenceCoordinator { if (stored === undefined) throw new Error(`session "${id}" not found`) this.assertStoredId(id, stored.meta) this.assertVersion(stored.meta) + const events = snapshotStoredEvents(stored.events, id) + this.assertEventsSupported(stored.meta, events) return { meta: structuredClone(stored.meta), - events: snapshotStoredEvents(stored.events, id), + events, } } @@ -839,6 +897,7 @@ export class PersistenceCoordinator { this.assertStoredId(id, meta) this.assertVersion(meta) const storedEvents = adoptStoredEvents(events, id) + this.assertEventsSupported(meta, storedEvents) // Preserve complete interrupted events and synthesize only missing closers. const closers = interruptedTurnClosers(storedEvents).map(adoptSessionEvent) @@ -861,6 +920,9 @@ export class PersistenceCoordinator { closers, } } catch (error: unknown) { + // An unsupported format is a refusal over an intact log, not damage — + // surface it unwrapped so callers can point at the raw artifact. + if (error instanceof SessionFormatUnsupportedError) throw error throw new SessionPersistenceCorruptionError( `stored session "${id}" failed validation: ${String(error)}`, { cause: error }, @@ -982,11 +1044,36 @@ export class PersistenceCoordinator { } private assertVersion(meta: SessionHeader): void { - if (meta.version !== SESSION_FORMAT_VERSION) { - throw new Error(`unsupported session format version ${meta.version} for "${meta.id}" (only v${SESSION_FORMAT_VERSION} is supported)`) + if (meta.version === SESSION_FORMAT_VERSION) return + throw this.unsupported(meta, sessionFormatVersionRefusal(meta.id, meta.version)) + } + + /** + * Refuse a log containing an event type this build does not know, unless the + * writer marked the event ignorable: an unrecognized required event may + * change how the rest of the log must be interpreted, so silently skipping + * it would reconstruct a wrong session (the envelope contract on + * `SessionEvent.ignorable`). Runs on NORMALIZED events — after + * `snapshotStoredEvents`/`adoptStoredEvents` has upgraded the legacy shapes + * this build still reads and rejected the ones it does not, so those keep + * their specific diagnostics. + */ + private assertEventsSupported(meta: SessionHeader, events: readonly SessionEvent[]): void { + for (const event of events) { + if (KNOWN_SESSION_EVENT_TYPES.has(event.type) || event.ignorable === true) continue + throw this.unsupported(meta, `session "${meta.id}" contains event type "${event.type}" (seq ${event.seq}) unknown to this harness and not marked ignorable; refusing to interpret the log — it was likely written by a newer harness`) } } + /** Build a format refusal that points at the raw artifact when the backend has one. */ + private unsupported(meta: SessionHeader, reason: string): SessionFormatUnsupportedError { + const location = this.backend.locate?.(meta) + return new SessionFormatUnsupportedError( + location === undefined ? reason : `${reason} (raw log: ${location.path})`, + location, + ) + } + /** Reject backend metadata that is not bound to the requested session id. */ private assertStoredId(id: SessionId, meta: SessionHeader): void { if (meta.id !== id) { @@ -1219,6 +1306,7 @@ export class PersistenceCoordinator { } this.assertVersion(meta) const storedEvents = snapshotStoredEvents(events, session.header.id) + this.assertEventsSupported(meta, storedEvents) if (!seedCoversPrefix(seed, storedEvents)) { throw new Error(`session "${session.header.id}" already has a persisted log on disk that does not match this live session (id collision)`) } diff --git a/packages/session/session-persistence/src/index.ts b/packages/session/session-persistence/src/index.ts index 5c3df73b30..aa01f68f7a 100644 --- a/packages/session/session-persistence/src/index.ts +++ b/packages/session/session-persistence/src/index.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-session-persistence */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { SessionPreparation } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId, SessionHeader } from '@deepseek-ai/dsh-session' import type { SessionPersistenceRevision } from './revision.ts' @@ -30,13 +30,25 @@ export interface SessionInspection { readonly events: readonly SessionEvent[] } +/** A backend's own raw artifact text for one session, verbatim. */ +export interface SessionRawArtifact { + /** The session header parsed from the artifact's own first line. */ + readonly meta: SessionHeader + /** The artifact's base filename on disk, without any physical encoding suffix. */ + readonly filename: string + /** The artifact's full text content, decoded from the backend's physical encoding. */ + readonly content: string +} + // The backend-agnostic write-path orchestration first-party backends compose. export { DEFAULT_PREPARED_SESSION_CACHE_SIZE, DEFAULT_WRITE_BATCH_MAX_DELAY_MS, MAX_WRITE_BATCH_DELAY_MS, PersistenceCoordinator, + SessionFormatUnsupportedError, SessionPersistenceCorruptionError, + sessionFormatVersionRefusal, } from './coordinator.ts' export type { PersistenceBackend, @@ -45,7 +57,7 @@ export type { StoredSuffix, } from './coordinator.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessionPersistence: SessionPersistence } @@ -83,6 +95,26 @@ export abstract class SessionPersistence extends Service { */ abstract locate(meta: SessionHeader): SessionLocation | undefined + /** + * Read a session's backend-owned artifact text verbatim — the exact durable + * bytes the backend wrote (decoded from its physical encoding, e.g. a + * decompressed JSONL). The returned `content` is the raw text, not a + * reconstruction from parsed events, so it preserves backend-specific + * serialization (chunk packing, key order, line breaks). Backends without a + * per-session artifact (SQLite) inherit the `undefined` default. + * @param _id - the persisted session to read (unused by the default: no + * per-session artifact). + * @param signal - optional cancellation for backend read work. + * @returns the raw artifact plus its parsed header, or `undefined` when the + * session is absent or the backend owns no per-session artifact. + */ + readRaw(_id: SessionId, signal?: AbortSignal): Promise { + if (signal?.aborted === true) { + return Promise.reject(signal.reason instanceof Error ? signal.reason : new Error('aborted')) + } + return Promise.resolve(undefined) + } + /** * Register a new session's metadata. A backend MAY defer the physical write * until the first {@link append} (lazy materialization), in which case a diff --git a/packages/session/session-persistence/src/invariant.ts b/packages/session/session-persistence/src/invariant.ts index 316774f3fd..4259c1b065 100644 --- a/packages/session/session-persistence/src/invariant.ts +++ b/packages/session/session-persistence/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-persistence' diff --git a/packages/session/session-persistence/tests/coordinator-contract.ts b/packages/session/session-persistence/tests/coordinator-contract.ts index 411df34d8d..8633faa886 100644 --- a/packages/session/session-persistence/tests/coordinator-contract.ts +++ b/packages/session/session-persistence/tests/coordinator-contract.ts @@ -11,7 +11,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' */ import { describe, expect, it, vi } from 'vitest' -import { Context, type Fiber } from 'cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import SessionStore, { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -706,6 +706,9 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< .rejects.toThrow('lacks an identified message') } + // An out-of-repo event type passes only with the envelope's ignorable + // marker (unknown-type refusal otherwise), and its non-object data is + // not message-validated. const pluginId = SessionId('non-object-plugin-event') await ctx.sessionPersistence.create(meta(pluginId, WORK)) await ctx.sessionPersistence.append(pluginId, [{ @@ -713,11 +716,12 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< seq: 0, time: 1, data: null, + ignorable: true, } as unknown as SessionEvent]) await expect(ctx.sessionPersistence.inspect(pluginId)) - .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] }) + .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] }) await expect(ctx.sessionPersistence.readFrom(pluginId, 0)) - .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null }] }) + .resolves.toMatchObject({ events: [{ type: 'plugin/test', data: null, ignorable: true }] }) for (const type of ['user/message', 'assistant/message'] as const) { const missingContentId = SessionId(`invalid-${type}-without-content`) @@ -1321,14 +1325,60 @@ export function runCoordinatorContract(name: string, makeFixture: () => Promise< } }) - it('rejects an unknown format version on load (assertVersion)', async () => { + it('rejects a newer format version on load, naming the upgrade direction', async () => { const fix = await makeFixture() const { ctx, fiber } = await freshCtx(fix) try { const m = { version: 99, id: SessionId('v99'), createdAt: 1, cwd: WORK } await ctx.sessionPersistence.create(m) await ctx.sessionPersistence.append(m.id, oneTurnLog()) - await expect(ctx.sessionPersistence.load(m.id)).rejects.toThrow(/version/) + const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/written by a newer harness.*upgrade the harness/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('rejects an older format version on load without claiming an upgrade path', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const m = { version: -1, id: SessionId('v-older'), createdAt: 1, cwd: WORK } + await ctx.sessionPersistence.create(m) + await ctx.sessionPersistence.append(m.id, oneTurnLog()) + const failure = await ctx.sessionPersistence.load(m.id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/older than the supported v0.*no upgrade path/) + } finally { + await fiber.dispose() + await fix.cleanup() + } + }) + + it('rejects an unknown event type on load unless the event is marked ignorable', async () => { + const fix = await makeFixture() + const { ctx, fiber } = await freshCtx(fix) + try { + const required = meta('unknown-required', WORK) + await ctx.sessionPersistence.create(required) + await ctx.sessionPersistence.append(required.id, [ + ...oneTurnLog(), + { type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 } } as unknown as SessionEvent, + ]) + const failure = await ctx.sessionPersistence.load(required.id).then(() => undefined, (error: unknown) => error as Error) + expect(failure?.name).toBe('SessionFormatUnsupportedError') + expect(failure?.message).toMatch(/event type "future\/event".*not marked ignorable/) + + const skippable = meta('unknown-ignorable', WORK) + await ctx.sessionPersistence.create(skippable) + await ctx.sessionPersistence.append(skippable.id, [ + ...oneTurnLog(), + { type: 'future/event', seq: oneTurnLog().length, time: 99, data: { payload: 1 }, ignorable: true } as unknown as SessionEvent, + ]) + const loaded = await ctx.sessionPersistence.load(skippable.id) + expect(loaded.events.some(event => (event.type as string) === 'future/event')).toBe(true) } finally { await fiber.dispose() await fix.cleanup() diff --git a/packages/session/session-persistence/tests/persistence.spec.ts b/packages/session/session-persistence/tests/persistence.spec.ts index d3e715b085..a09516df29 100644 --- a/packages/session/session-persistence/tests/persistence.spec.ts +++ b/packages/session/session-persistence/tests/persistence.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { Session, SessionId, isJsonValue } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionHeader } from '@deepseek-ai/dsh-session' import { @@ -246,6 +246,24 @@ runPersistenceContract('memory', async () => { } }) +describe('the inherited readRaw default', () => { + it('answers undefined and honors an aborted signal', async () => { + const ctx = new Context() + await ctx.plugin(SessionStore) + await ctx.plugin(MemoryPersistence) + expect(await ctx.sessionPersistence.readRaw(SessionId('any-session'))).toBeUndefined() + await expect( + ctx.sessionPersistence.readRaw(SessionId('any-session'), AbortSignal.abort()), + ).rejects.toThrow() + // A non-Error abort reason falls back to a wrapped Error rejection. + const controller = new AbortController() + controller.abort('boom') + await expect( + ctx.sessionPersistence.readRaw(SessionId('any-session'), controller.signal), + ).rejects.toThrow('aborted') + }) +}) + // Each fixture shares one map across mounts. No `corruptTail` is supplied because map writes are // atomic; the suite asserts that skip while JSONL and SQLite cover the repair branch. runCoordinatorContract('memory', async (): Promise => { diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index 2be692665f..59c3cd2a57 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-projection-cache", "description": "Persisted projection cache (ctx.sessionProjectionCache): durable per-session projection checkpoints over the domain data form, throttled write-behind, and the cold-read ladder (cache row + persistence tail replay)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-projection-cache" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-session-projection": "^0.0.1", - "@deepseek-ai/dsh-storage-domain": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -43,6 +50,6 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-projection-cache/src/index.ts b/packages/session/session-projection-cache/src/index.ts index 03b3ed26ee..66ad637402 100644 --- a/packages/session/session-projection-cache/src/index.ts +++ b/packages/session/session-projection-cache/src/index.ts @@ -12,8 +12,8 @@ * @module @deepseek-ai/dsh-session-projection-cache */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' // Empty type import: applies the package's cordis Context merge @@ -27,7 +27,7 @@ import type { CheckpointIdentity, CheckpointRecord } from './spec.ts' export { checkpointIdentity, checkpointRecord, checkpointRow, projectionCacheDomainSpec } from './spec.ts' export type { CheckpointIdentity, CheckpointRecord } from './spec.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessionProjectionCache: SessionProjectionCache } diff --git a/packages/session/session-projection-cache/src/invariant.ts b/packages/session/session-projection-cache/src/invariant.ts index 8a119044d8..886f913d30 100644 --- a/packages/session/session-projection-cache/src/invariant.ts +++ b/packages/session/session-projection-cache/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-projection-cache' diff --git a/packages/session/session-projection-cache/tests/cache.spec.ts b/packages/session/session-projection-cache/tests/cache.spec.ts index ba9dc39c57..6395ed7d0f 100644 --- a/packages/session/session-projection-cache/tests/cache.spec.ts +++ b/packages/session/session-projection-cache/tests/cache.spec.ts @@ -7,7 +7,7 @@ */ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import Storage from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' diff --git a/packages/session/session-projection/README.i18n.yaml b/packages/session/session-projection/README.i18n.yaml index c7b7530b09..f2103cd320 100644 --- a/packages/session/session-projection/README.i18n.yaml +++ b/packages/session/session-projection/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/session/session-projection/README.md -README.md: 2615b253999c798172168ec9d0232eb965b07fbc -README.zh.md: 0712e9c7a61fbcf43939791b3e7cd24af6777c3a +README.md: 9018b133bb69ed4717fede14c9a2070a07c3fa62 +README.zh.md: b91908117fd452855a82976515d13165803def43 diff --git a/packages/session/session-projection/README.md b/packages/session/session-projection/README.md index 2615b25399..9018b133bb 100644 --- a/packages/session/session-projection/README.md +++ b/packages/session/session-projection/README.md @@ -42,6 +42,7 @@ None; projections never assemble or send provider requests. ## Known Limitations and Deferred Work - **Every tail page carries every registered key** — there is no per-key opt-out or lazy-key request shape yet; acceptable while values are UI-scale whole states (a todo list, a goal snapshot), revisit if a domain's value grows large. +- **The unit table is process-wide, so key presence is not a per-session capability signal** — a key registered by ANY agent preset appears in every session's snapshot, including sessions whose own composition mounts nothing that produces it. A client must read the VALUE (`plan.active`, an empty todo list) rather than treat an absent key as absence of the feature; a unit whose empty value is indistinguishable from a real one belongs on the host plane instead, which is why `dsh-token-meter` sits there. - **Eager drive touches every unit per event** — cheap by construction (whole-value rule, same-reference gate), but a hot path would justify per-unit event-type prefilters, addable without contract change. - **Registry cells live in memory only** — a restart rebuilds by folding the log on first touch; compositions that mount `dsh-session-projection-cache` seed that fold from persisted rows instead. - **Synchronous unit discipline is only partially mechanical** — the boundary `schema.parse` rejects a Promise-returning `view`, but an `apply` that blocks or reads torn non-session state is a review concern; the invariant companion documents why no runtime check exists. diff --git a/packages/session/session-projection/README.zh.md b/packages/session/session-projection/README.zh.md index 0712e9c7a6..b91908117f 100644 --- a/packages/session/session-projection/README.zh.md +++ b/packages/session/session-projection/README.zh.md @@ -42,6 +42,7 @@ ## 已知限制与暂缓事项 - **每个尾页携带每个已注册的 key**——尚无逐 key 的 opt-out 或惰性 key 请求形状;在值都是 UI 量级的全量状态(一张 todo 清单、一份 goal 快照)时可以接受,若某领域的值变大再重议。 +- **单元表是进程级的,因此 key 是否存在不能当作逐会话的能力信号**——只要**任何**一个 agent preset 注册了某个 key,它就出现在每个会话的快照里,包括自身组装完全不产出该值的会话。客户端必须读**值**(`plan.active`、空的 todo 列表),不能把 key 缺席当作功能缺席;如果某个单元的空值与真实值无法区分,它就该待在宿主平面——`dsh-token-meter` 正因如此留在那里。 - **主动驱动(eager drive)逐事件触达每个单元**——按构造开销很低(全量值规则、同引用闸门),但若出现热点路径,可加按单元的事件类型预过滤,约定不变。 - **注册表 cell 只活在内存里**——重启后首次触达时靠折叠日志重建;挂载了 `dsh-session-projection-cache` 的组合改由持久行播种该折叠。 - **单元同步纪律只有部分可机械把关**——边界 `schema.parse` 能拒绝返回 Promise 的 `view`,但阻塞的 `apply`、或读取撕裂的非会话状态的 `apply`,只能靠评审把关;invariant 配套记载了为何不存在运行时检查。 diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index 37a081abfa..fee23d3970 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-projection", "description": "Session-projection seam: the merge-extensible projection type table, the provider contract, and the ctx.sessionProjections registry serving whole current values of log-derived per-session state", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-projection" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -33,13 +40,13 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-projection/src/index.ts b/packages/session/session-projection/src/index.ts index 154a5ca2fa..9f0c24e72e 100644 --- a/packages/session/session-projection/src/index.ts +++ b/packages/session/session-projection/src/index.ts @@ -17,11 +17,11 @@ * @module @deepseek-ai/dsh-session-projection */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { ZodType } from 'zod' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessionProjections: SessionProjectionRegistry } diff --git a/packages/session/session-projection/src/invariant.ts b/packages/session/session-projection/src/invariant.ts index 47934c946c..537015d932 100644 --- a/packages/session/session-projection/src/invariant.ts +++ b/packages/session/session-projection/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-projection' diff --git a/packages/session/session-projection/tests/registry.spec.ts b/packages/session/session-projection/tests/registry.spec.ts index 5d0f208743..4ae4a3face 100644 --- a/packages/session/session-projection/tests/registry.spec.ts +++ b/packages/session/session-projection/tests/registry.spec.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import SessionStore from '@deepseek-ai/dsh-session' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-telemetry-otel/README.i18n.yaml b/packages/session/session-telemetry-otel/README.i18n.yaml index 2897eb7dac..161f201cfb 100644 --- a/packages/session/session-telemetry-otel/README.i18n.yaml +++ b/packages/session/session-telemetry-otel/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/session/session-telemetry-otel/README.md -README.md: 585995ce409255df9608bc33b76625374bc67669 -README.zh.md: 7f0b93363fbb4aebb80f0d3cc8108e58ce3f647f +README.md: e3eae475a180419c7822d51858ae156052a663d6 +README.zh.md: cfdf36ac5783850cc5e63bbb2b622584f1064b0c diff --git a/packages/session/session-telemetry-otel/README.md b/packages/session/session-telemetry-otel/README.md index 585995ce40..e3eae475a1 100644 --- a/packages/session/session-telemetry-otel/README.md +++ b/packages/session/session-telemetry-otel/README.md @@ -29,6 +29,8 @@ Programmatic TypeScript configuration uses the exported `TelemetryMode` enum (`T Upload authorization is positive and fail-closed. An unknown direct-construction mode fails before transport configuration is read. Only `FULL` accepts direct `ctx.telemetry.emit()` calls. `FEEDBACK_ONLY` gives its on-demand coordinator a private backend capability and treats only the exact `feedback/record` object already stored at `session.events[event.seq]` as consent; an independently emitted bus value is ignored. `DISABLED` never constructs the SDK pipeline, even when exporter options are present. +The mounted service discloses the resolved mode through the seam's [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` property (`full` / `feedback-only` / `disabled`), so the `/feedback` acknowledgement can report whether and how the session is shared. The disclosure is set in the constructor and is independent of capture: even `DISABLED` discloses `disabled`. + `exporter.url` is required in `FULL` and `FEEDBACK_ONLY`, has no default, and must parse as `http(s)`; it is optional and unused in `DISABLED`. In uploading modes, `shutdownTimeoutMillis` is a positive finite DSH-owned outer deadline that defaults to 3000 ms, and a non-positive-integer `processor.maxExportBatchSize` also fails at plugin load because the SDK accepts it but then hangs on shutdown. Both SDK blocks pass through whole: every `OTLPExporterNodeConfigBase` field (`headers`, `timeoutMillis`, `compression`, `keepAlive`, …) reaches the exporter, and batching, export cadence (`scheduledDelayMillis`), retry, queue bounds, and loss policy under sustained failure are SDK behavior tuned through `processor`. The backend implements no `flush()`: the batch processor owns ordinary flushing. During shutdown, OTel awaits `exporter.forceFlush()` before the processor's `exportTimeoutMillis`-bounded completion promise; if that transport promise never settles, this package abandons the wait at `shutdownTimeoutMillis`, logs the contained shutdown failure through the coordinator, and lets application teardown continue. The deadline cannot cancel the SDK transport, so records still pending then may be lost at process exit. ## What leaves the machine diff --git a/packages/session/session-telemetry-otel/README.zh.md b/packages/session/session-telemetry-otel/README.zh.md index 7f0b93363f..cfdf36ac57 100644 --- a/packages/session/session-telemetry-otel/README.zh.md +++ b/packages/session/session-telemetry-otel/README.zh.md @@ -29,6 +29,8 @@ 上传授权采用显式许可,且为 fail-closed。通过直接构造传入未知模式时,会在读取传输配置前失败。只有 `FULL` 接受对 `ctx.telemetry.emit()` 的直接调用。`FEEDBACK_ONLY` 向其按需协调器提供私有后端能力,并且仅在 `feedback/record` 对象已经存储于 `session.events[event.seq]` 且对象身份完全相同时,才将其视为同意;独立发出的总线值会被忽略。即使存在导出器选项,`DISABLED` 也绝不会构造 SDK 流水线。 +已挂载的服务通过 seam 的 [`TelemetrySharingStatus`](../session-telemetry/README.md#the-sharing-disclosure) `sharing` 属性披露解析后的模式(`full` / `feedback-only` / `disabled`),因此 `/feedback` 的确认文本可以报告会话是否以及如何被共享。该披露在构造函数中设置,与采集相互独立:即使 `DISABLED` 也会披露 `disabled`。 + `exporter.url` 在 `FULL` 与 `FEEDBACK_ONLY` 中必填,无默认值,且必须能解析为 `http(s)`;在 `DISABLED` 中可省略且不使用。在上传模式中,`shutdownTimeoutMillis` 是由 DSH 管理的有限正数外层截止时间,默认值为 3000 ms;`processor.maxExportBatchSize` 不是正整数时也会在插件加载时失败,因为 SDK 会接受该值,随后却在关闭时挂起。两个 SDK 配置块都整体透传(passthrough):`OTLPExporterNodeConfigBase` 的每个字段(`headers`、`timeoutMillis`、`compression`、`keepAlive` 等)都会到达导出器;批处理、导出节奏(`scheduledDelayMillis`)、重试、队列上限,以及持续失败下的丢失策略,都是通过 `processor` 调节的 SDK 行为。该后端不实现 `flush()`:常规 flush 由批处理器负责。关闭期间,OTel 会先等待 `exporter.forceFlush()`,再等待受处理器 `exportTimeoutMillis` 限制的完成 promise;如果该传输 promise 始终不结算,本包会在 `shutdownTimeoutMillis` 到期时放弃等待,通过协调器记录已隔离的关闭失败,并让应用继续拆卸。该截止时间无法取消 SDK 传输,因此届时仍待处理的记录可能在进程退出时丢失。 ## 哪些数据会离开本机 diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 2af0b5294e..d5229618a8 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-telemetry-otel", "description": "OpenTelemetry backend for the DeepSeek Harness telemetry seam: hands captured session records to the OTel JS SDK's log pipeline", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-telemetry-otel" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -31,25 +38,25 @@ "@opentelemetry/otlp-exporter-base": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-command-feedback": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-telemetry": "^0.0.1", - "@deepseek-ai/dsh-user-id": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", "@deepseek-ai/dsh-command-feedback": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-user-id": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-command-feedback": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-telemetry": "workspace:^", + "@deepseek-ai/dsh-user-id": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-telemetry-otel/src/index.ts b/packages/session/session-telemetry-otel/src/index.ts index 50776f7d3f..1f208394ff 100644 --- a/packages/session/session-telemetry-otel/src/index.ts +++ b/packages/session/session-telemetry-otel/src/index.ts @@ -13,8 +13,8 @@ */ import { createRequire } from 'node:module' -import z from 'schemastery' -import type { Context } from 'cordis' +import z from '@deepseek-ai/schemastery' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-command-feedback' import { Telemetry, @@ -22,6 +22,7 @@ import { type TelemetryBackend, type TelemetryRecord, type TelemetrySeverity, + type TelemetrySharingStatus, } from '@deepseek-ai/dsh-session-telemetry' import { APP_IDENTITY } from '@deepseek-ai/dsh-llm' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' @@ -71,6 +72,17 @@ function assertNever(value: never): never { throw new Error(`session-telemetry-otel: unsupported mode ${JSON.stringify(value)}`) } +/** Map the serialized mode onto the seam's backend-independent sharing vocabulary. */ +function sharingStatusFor(mode: TelemetryMode): TelemetrySharingStatus { + switch (mode) { + case TelemetryMode.FULL: return 'full' + case TelemetryMode.FEEDBACK_ONLY: return 'feedback-only' + case TelemetryMode.DISABLED: return 'disabled' + /* v8 ignore next 2 -- resolveMode already rejected unknown values before this switch; the closed enum cannot reach the default. */ + default: return assertNever(mode) + } +} + /** * Plugin configuration: one sharing policy, two verbatim SDK option objects, * and one DSH-owned shutdown bound. Uploading modes validate their endpoint @@ -139,10 +151,12 @@ export class TelemetryOtel extends Telemetry { private readonly directEmit: TelemetryBackend['emit'] private readonly provider: LoggerProvider | undefined private readonly shutdownTimeoutMillis: number + override readonly sharing: TelemetrySharingStatus constructor(ctx: Context, config: Config) { const mode = resolveMode(config.mode) super(ctx) + this.sharing = sharingStatusFor(mode) if (mode === TelemetryMode.DISABLED) { this.directEmit = DROP_RECORD this.provider = undefined diff --git a/packages/session/session-telemetry-otel/src/invariant.ts b/packages/session/session-telemetry-otel/src/invariant.ts index 0eaffeb8b5..31864acb1c 100644 --- a/packages/session/session-telemetry-otel/src/invariant.ts +++ b/packages/session/session-telemetry-otel/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry-otel' diff --git a/packages/session/session-telemetry-otel/tests/otel.spec.ts b/packages/session/session-telemetry-otel/tests/otel.spec.ts index 511c95c0d8..a5bb9d06ae 100644 --- a/packages/session/session-telemetry-otel/tests/otel.spec.ts +++ b/packages/session/session-telemetry-otel/tests/otel.spec.ts @@ -12,9 +12,9 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { gunzipSync } from 'node:zlib' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { getOrCreateAnonymousUserId } from '@deepseek-ai/dsh-user-id' -import Loader from '@cordisjs/plugin-loader' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { recordFeedback } from '@deepseek-ai/dsh-command-feedback' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import TelemetryOtel, { Config, DEFAULT_TELEMETRY_MODE, TelemetryMode } from '../src/index.ts' @@ -364,6 +364,31 @@ describe('TelemetryOtel wire', () => { expect(captures).toEqual([]) }) + it('discloses the sharing policy for every mode', async () => { + const { url, captures } = await mockCollector() + + const fullCtx = new Context() + await fullCtx.plugin(SessionStore) + const full = await fullCtx.plugin(TelemetryOtel, { exporter: { url } }) + expect(fullCtx.telemetry.sharing).toBe('full') + await full.dispose() + + const gatedCtx = new Context() + await gatedCtx.plugin(SessionStore) + const gated = await gatedCtx.plugin(TelemetryOtel, { mode: TelemetryMode.FEEDBACK_ONLY, exporter: { url } }) + expect(gatedCtx.telemetry.sharing).toBe('feedback-only') + await gated.dispose() + + const disabledCtx = new Context() + await disabledCtx.plugin(SessionStore) + const disabled = await disabledCtx.plugin(TelemetryOtel, { mode: TelemetryMode.DISABLED }) + expect(disabledCtx.telemetry.sharing).toBe('disabled') + await disabled.dispose() + + // No record was emitted by any mode, so nothing reached the collector. + expect(captures).toEqual([]) + }) + it('defaults direct construction to full delivery', async () => { const { url, captures } = await mockCollector() const ctx = new Context() diff --git a/packages/session/session-telemetry/README.i18n.yaml b/packages/session/session-telemetry/README.i18n.yaml index 3d4650361f..4b3169d5fe 100644 --- a/packages/session/session-telemetry/README.i18n.yaml +++ b/packages/session/session-telemetry/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/session/session-telemetry/README.md -README.md: 827554dd53a81eab5a5fd7f145df3f835db9c173 -README.zh.md: a350ea5935a2143cb0f876eeb1eb0520ffee5c53 +README.md: 707dcfcdb0c8dfbd622630351928ac43562535ec +README.zh.md: bd080adceebf83cd9e53d72a7093db376cf6cbd1 diff --git a/packages/session/session-telemetry/README.md b/packages/session/session-telemetry/README.md index 827554dd53..707dcfcdb0 100644 --- a/packages/session/session-telemetry/README.md +++ b/packages/session/session-telemetry/README.md @@ -8,6 +8,12 @@ The telemetry Service Definition declares the `TelemetryBackend` contract, and i `TelemetryBackend` has three members: `emit(record)` MUST enqueue without blocking because it runs synchronously during `session/event` or explicit canonical-log replay; optional `flush()` is a fire-and-forget hint after a turn ends, and most backends omit it and use their SDK's normal batching schedule; `shutdown()` drains queued records and resolves when the SDK stops, and disposal awaits it. An implementation that provides `flush()` must order concurrent flushes with the final `shutdown()` drain. `Telemetry` registers this API under the `telemetry` context key; each context accepts one implementation, and a duplicate load throws. A backend constructs `TelemetryCoordinator` with `live` or `on-demand` capture and calls `captureSession(session, throughSeq?)` at its chosen trigger. +The service also carries the required [`TelemetrySharingStatus`](#the-sharing-disclosure) `sharing` member: the deployment-selected sharing policy every backend must disclose to human-facing acknowledgement surfaces (the `/feedback` command's confirmation). A consumer renders "not configured" only when no telemetry service is mounted. The seam owns the vocabulary (`full` | `feedback-only` | `disabled`) so any backend can disclose a policy without depending on the OTel package. + +## The sharing disclosure + +The acknowledgement of a recorded feedback entry reports whether and how the session is shared, read from the mounted backend's `sharing`. A backend sets the property from its deployment configuration: `full` (every event is handed over as it happens), `feedback-only` (nothing is handed over until a `feedback/record` event releases the unreleased prefix through it), or `disabled` (nothing is handed over at all). Consumers map the status onto user-facing copy; the disclosure never claims delivery — handoff is the non-blocking enqueue, and batching, retry, and loss policy stay the backend SDK's. + ## Capture points In `live` mode the coordinator registers, all through the composing fiber's effects: `session/created` (adopt: record the header, read the log back through the projection from the construction boundary — constructor seeds from fork/resume never re-emit on the firehose and never re-export), `session/event` (project, deep-copy, redact, then hand off; zero I/O), `session/flush` (forward the optional `flush()` hint and return void — the loop's awaited parallel must never wait on telemetry), `session/disposed` (capture the session's `shutdown` operational record at its termination edge, then retire it), `agent/error` (the one live-bus relay; the session event vocabulary intentionally has no operational-error record), a dispose effect (capture shutdown for each still-live session, then await the backend's `shutdown()`; failures warn instead of throwing), and an adoption sweep of `ctx.sessions.list()` (a hot reload does not replay `session/created`). In `on-demand` mode it registers only the dispose effect: `captureSession()` reads the canonical log through an optional inclusive sequence boundary, while flush hints and operational events remain local. diff --git a/packages/session/session-telemetry/README.zh.md b/packages/session/session-telemetry/README.zh.md index a350ea5935..bd080adcee 100644 --- a/packages/session/session-telemetry/README.zh.md +++ b/packages/session/session-telemetry/README.zh.md @@ -8,6 +8,14 @@ `TelemetryBackend` 有三个成员:`emit(record)` 必须入队且不能阻塞,因为它会在 `session/event` 或显式权威日志回放期间同步执行;可选的 `flush()` 是轮次结束后的提示,调用方不等待结果,多数后端省略它并使用 SDK 的常规批处理计划;`shutdown()` 排空已入队记录,并在 SDK 停止后结束,dispose(资源释放)会等待它。提供 `flush()` 的实现必须安排并发 flush 与 `shutdown()` 最终排空的先后顺序。`Telemetry` 将此 API 注册在 `telemetry` 上下文键下:每个上下文只允许一个实现,重复加载会抛出异常。后端以 `live` 或 `on-demand` 捕获构造 `TelemetryCoordinator`,并在自己选择的触发器中调用 `captureSession(session, throughSeq?)`。 +该服务还携带必需的 [`TelemetrySharingStatus`](#the-sharing-disclosure) `sharing` 成员:每个后端都必须向面向用户的确认 surface(`/feedback` 命令的确认文本)披露的部署级共享策略。消费方只有在未挂载任何遥测服务时才渲染「未配置」。seam 拥有该词汇(`full` | `feedback-only` | `disabled`),因此任何后端都可以披露策略,而无需依赖 OTel 包。 + + + +## 共享披露 + +一条已记录的反馈条目的确认文本会报告该会话是否以及如何被共享,读取自已挂载后端的 `sharing`。后端根据其部署配置设置该属性:`full`(每个事件在发生时立即交接)、`feedback-only`(在 `feedback/record` 事件释放其之前的未释放前缀之前,不交接任何内容)或 `disabled`(完全不交接任何内容)。消费方把状态映射为面向用户的文案;披露从不声称投递——交接是非阻塞入队,批处理、重试与丢失策略仍归后端 SDK。 + ## 捕获点 在 `live` 模式中,协调器的全部注册都经由组合方 fiber 的 effect 完成:`session/created`(收养:记录 header,并经投影从构造边界起回读日志;来自 fork 或恢复的构造函数种子绝不会在 firehose 上再次发出,也绝不会再次导出)、`session/event`(投影、深拷贝、脱敏,再交接;零 I/O)、`session/flush`(转发可选的 `flush()` 提示并返回 void;循环所等待的并行任务绝不能等待遥测)、`session/disposed`(在会话自身的终止边缘捕获该会话的 `shutdown` 运维记录,然后将其退役)、`agent/error`(唯一的实时总线转发;会话事件词汇有意不包含运维错误记录)、一个 dispose effect(捕获每个仍存活会话的 shutdown,再等待后端的 `shutdown()`;失败只发出警告而不抛出),以及对 `ctx.sessions.list()` 的收养扫描(热重载不会重放 `session/created`)。在 `on-demand` 模式中,协调器只注册 dispose effect:`captureSession()` 读取权威日志,直至可选的序列号边界(含边界);flush 提示与运维事件留在本地。 diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index ff6e31b025..8dc9a7fb67 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-telemetry", "description": "Telemetry seam for the DeepSeek Harness: session-event capture, projection, redaction, and handoff to a reporting backend", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-telemetry" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-telemetry/src/coordinator.ts b/packages/session/session-telemetry/src/coordinator.ts index f06d1b7b9e..2d092b4fd7 100644 --- a/packages/session/session-telemetry/src/coordinator.ts +++ b/packages/session/session-telemetry/src/coordinator.ts @@ -14,7 +14,7 @@ * @module @deepseek-ai/dsh-session-telemetry/coordinator */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { TelemetryBackend, TelemetryRecord, TelemetrySeverity } from './index.ts' diff --git a/packages/session/session-telemetry/src/index.ts b/packages/session/session-telemetry/src/index.ts index 7ddd85fe8e..0900d9cdff 100644 --- a/packages/session/session-telemetry/src/index.ts +++ b/packages/session/session-telemetry/src/index.ts @@ -14,9 +14,9 @@ * @module @deepseek-ai/dsh-session-telemetry */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { telemetry: Telemetry } @@ -130,6 +130,15 @@ export interface TelemetryBackend { shutdown(): Promise } +/** + * Deployment-selected session-sharing policy disclosed by a mounted + * {@link Telemetry} backend to human-facing acknowledgement surfaces (the + * `/feedback` command's confirmation text). The seam owns the vocabulary so + * any backend can disclose a policy without depending on the OTel package; + * the values mirror the OTel backend's serialized `TelemetryMode` choices. + */ +export type TelemetrySharingStatus = 'full' | 'feedback-only' | 'disabled' + /** * Loadable form of the backend contract: one implementation per context — * the cordis `Service` registration under the `telemetry` key throws on a @@ -141,6 +150,15 @@ export abstract class Telemetry extends Service implements TelemetryBackend { super(ctx, 'telemetry') } + /** + * Deployment-selected session-sharing policy, disclosed for acknowledgement + * surfaces that report whether recorded feedback leaves the process. Every + * backend must disclose its policy; a consumer renders "not configured" only + * when no telemetry service is mounted. The seam owns this vocabulary so the + * disclosure is backend-independent. + */ + abstract readonly sharing: TelemetrySharingStatus + /** * See {@link TelemetryBackend.emit} — that declaration is the contract's one home. * @param record - the logical record to report; owned by the backend after the call. diff --git a/packages/session/session-telemetry/src/invariant.ts b/packages/session/session-telemetry/src/invariant.ts index 1c265877f8..ec0928a868 100644 --- a/packages/session/session-telemetry/src/invariant.ts +++ b/packages/session/session-telemetry/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-telemetry' diff --git a/packages/session/session-telemetry/tests/redact.spec.ts b/packages/session/session-telemetry/tests/redact.spec.ts index f20891fc0f..15df6f380a 100644 --- a/packages/session/session-telemetry/tests/redact.spec.ts +++ b/packages/session/session-telemetry/tests/redact.spec.ts @@ -6,7 +6,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import { TelemetryCoordinator, diff --git a/packages/session/session-telemetry/tests/telemetry.spec.ts b/packages/session/session-telemetry/tests/telemetry.spec.ts index 029381fe78..8d758f56b3 100644 --- a/packages/session/session-telemetry/tests/telemetry.spec.ts +++ b/packages/session/session-telemetry/tests/telemetry.spec.ts @@ -7,7 +7,7 @@ import { createToolResultMessage, createUserMessage } from '@deepseek-ai/dsh-llm */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId, type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import { diff --git a/packages/session/session-title-all-messages-llm/package.json b/packages/session/session-title-all-messages-llm/package.json index 546387c682..d333b5d5ca 100644 --- a/packages/session/session-title-all-messages-llm/package.json +++ b/packages/session/session-title-all-messages-llm/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-title-all-messages-llm", "description": "All-user-messages LLM provider plugin for DeepSeek Harness session titles", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-title-all-messages-llm" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -20,15 +27,15 @@ "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-title": "^0.0.1", - "@deepseek-ai/dsh-session-title-llm": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-session-title-llm": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -36,6 +43,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-title-all-messages-llm/src/index.ts b/packages/session/session-title-all-messages-llm/src/index.ts index 96bd424434..168aa4b98f 100644 --- a/packages/session/session-title-all-messages-llm/src/index.ts +++ b/packages/session/session-title-all-messages-llm/src/index.ts @@ -1,7 +1,7 @@ /** All-human-messages model provider for `ctx.sessionTitle`. */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { registerSessionTitleLlmProvider, SessionTitleLlmConfigFields, diff --git a/packages/session/session-title-all-messages-llm/src/invariant.ts b/packages/session/session-title-all-messages-llm/src/invariant.ts index 79f6eb55ee..1e344d85d9 100644 --- a/packages/session/session-title-all-messages-llm/src/invariant.ts +++ b/packages/session/session-title-all-messages-llm/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-all-messages-llm' diff --git a/packages/session/session-title-all-messages-llm/tests/provider.spec.ts b/packages/session/session-title-all-messages-llm/tests/provider.spec.ts index 3c6ef6ed0b..6eca8abcbf 100644 --- a/packages/session/session-title-all-messages-llm/tests/provider.spec.ts +++ b/packages/session/session-title-all-messages-llm/tests/provider.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import LlmService, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/session/session-title-first-message-llm/package.json b/packages/session/session-title-first-message-llm/package.json index 50b4a4d14c..f259b41435 100644 --- a/packages/session/session-title-first-message-llm/package.json +++ b/packages/session/session-title-first-message-llm/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-title-first-message-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-title-first-message-llm" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -20,25 +27,25 @@ "files": ["lib/index.js", "lib/invariant.js", "lib/types/**/*.d.ts"], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-title": "^0.0.1", - "@deepseek-ai/dsh-session-title-llm": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-session-title-llm": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-title-first-message-llm/src/index.ts b/packages/session/session-title-first-message-llm/src/index.ts index 51cc8eab44..15aece3fb8 100644 --- a/packages/session/session-title-first-message-llm/src/index.ts +++ b/packages/session/session-title-first-message-llm/src/index.ts @@ -1,7 +1,7 @@ /** First-human-message model provider for `ctx.sessionTitle`. */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { registerSessionTitleLlmProvider, SessionTitleLlmConfigFields, diff --git a/packages/session/session-title-first-message-llm/src/invariant.ts b/packages/session/session-title-first-message-llm/src/invariant.ts index bd3662496f..a2e1b5ff91 100644 --- a/packages/session/session-title-first-message-llm/src/invariant.ts +++ b/packages/session/session-title-first-message-llm/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-first-message-llm' diff --git a/packages/session/session-title-first-message-llm/tests/loader-composition.spec.ts b/packages/session/session-title-first-message-llm/tests/loader-composition.spec.ts index eb0125efd9..28915308a9 100644 --- a/packages/session/session-title-first-message-llm/tests/loader-composition.spec.ts +++ b/packages/session/session-title-first-message-llm/tests/loader-composition.spec.ts @@ -1,7 +1,7 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/session/session-title-first-message-llm/tests/provider.e2e.ts b/packages/session/session-title-first-message-llm/tests/provider.e2e.ts index 6f39669cc8..970c45b851 100644 --- a/packages/session/session-title-first-message-llm/tests/provider.e2e.ts +++ b/packages/session/session-title-first-message-llm/tests/provider.e2e.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import * as LlmDeepSeek from '@deepseek-ai/dsh-llm-deepseek' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-title-first-message-llm/tests/provider.spec.ts b/packages/session/session-title-first-message-llm/tests/provider.spec.ts index 79a80745e3..38d41857ed 100644 --- a/packages/session/session-title-first-message-llm/tests/provider.spec.ts +++ b/packages/session/session-title-first-message-llm/tests/provider.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import LlmService, { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 64e4519662..dff4c3fde3 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-title-llm" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-title": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-title": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -41,6 +48,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-title-llm/src/index.ts b/packages/session/session-title-llm/src/index.ts index 52572db30d..711d9c5f16 100644 --- a/packages/session/session-title-llm/src/index.ts +++ b/packages/session/session-title-llm/src/index.ts @@ -4,8 +4,8 @@ * @module @deepseek-ai/dsh-session-title-llm */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { createUserMessage, BlockAssembler, deepFreeze } from '@deepseek-ai/dsh-llm' import type { FinishReason, GenerateOptions, Message } from '@deepseek-ai/dsh-llm' import { deadline, MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' diff --git a/packages/session/session-title-llm/src/invariant.ts b/packages/session/session-title-llm/src/invariant.ts index 64350ebf20..4c0a98e830 100644 --- a/packages/session/session-title-llm/src/invariant.ts +++ b/packages/session/session-title-llm/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-session-title-llm' diff --git a/packages/session/session-title-llm/tests/llm.spec.ts b/packages/session/session-title-llm/tests/llm.spec.ts index 6572eb89c5..48ae497a7b 100644 --- a/packages/session/session-title-llm/tests/llm.spec.ts +++ b/packages/session/session-title-llm/tests/llm.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import LlmService, { createUserMessage, CallId, isAgentLoopRequest, LlmAdapter } from '@deepseek-ai/dsh-llm' import type { FinishReason, GenerateOptions, StreamChunk } from '@deepseek-ai/dsh-llm' diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index d167b257b2..5ae395c008 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/session-title" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -34,15 +41,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-projection": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { @@ -53,6 +60,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-title/src/index.ts b/packages/session/session-title/src/index.ts index 6f8594edd4..8017555ad3 100644 --- a/packages/session/session-title/src/index.ts +++ b/packages/session/session-title/src/index.ts @@ -3,8 +3,8 @@ * @module @deepseek-ai/dsh-session-title */ -import { Context, FiberState, Service, type Fiber } from 'cordis' -import z from 'schemastery' +import { Context, FiberState, Service, type Fiber } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { Branded } from '@deepseek-ai/dsh-brand' import { assertNever, deepFreeze, isAgentLoopRequest } from '@deepseek-ai/dsh-llm' @@ -85,7 +85,7 @@ export interface Config { readonly maxTitleBytes: number } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { sessionTitle: SessionTitleService } diff --git a/packages/session/session-title/src/invariant.ts b/packages/session/session-title/src/invariant.ts index a826104489..11337fbf34 100644 --- a/packages/session/session-title/src/invariant.ts +++ b/packages/session/session-title/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-title/tests/invariant.spec.ts b/packages/session/session-title/tests/invariant.spec.ts index d1a6abb527..68c82146a4 100644 --- a/packages/session/session-title/tests/invariant.spec.ts +++ b/packages/session/session-title/tests/invariant.spec.ts @@ -1,7 +1,7 @@ // Title-source invariant: `messageSeqs` is empty iff `source.kind` is `user`. // — the durable relationship every appended session/title event must keep. import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as SessionTitleInvariantCompanion from '@deepseek-ai/dsh-session-title/invariant' import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-title/tests/persistence.spec.ts b/packages/session/session-title/tests/persistence.spec.ts index 7d5428983f..3f9ab211d5 100644 --- a/packages/session/session-title/tests/persistence.spec.ts +++ b/packages/session/session-title/tests/persistence.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/session/session-title/tests/projection.spec.ts b/packages/session/session-title/tests/projection.spec.ts index 986cafc620..1198e78a23 100644 --- a/packages/session/session-title/tests/projection.spec.ts +++ b/packages/session/session-title/tests/projection.spec.ts @@ -10,7 +10,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import type { Session } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' diff --git a/packages/session/session-title/tests/provider.spec.ts b/packages/session/session-title/tests/provider.spec.ts index 5bfe30ac14..65d913677c 100644 --- a/packages/session/session-title/tests/provider.spec.ts +++ b/packages/session/session-title/tests/provider.spec.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import LlmService, { createUserMessage, deepFreeze, markAgentLoopRequest } from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-title/tests/rename.spec.ts b/packages/session/session-title/tests/rename.spec.ts index ff38900f47..76dfc57e30 100644 --- a/packages/session/session-title/tests/rename.spec.ts +++ b/packages/session/session-title/tests/rename.spec.ts @@ -1,7 +1,7 @@ // SessionTitleService.rename: user-source acceptance, normalization/rejection // boundaries, and the pin (a user-sourced latest title schedules no automatic // revision; explicit refresh stays the unpin). -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import { createUserMessage } from '@deepseek-ai/dsh-llm' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/session/session-title/tests/service-contracts.spec.ts b/packages/session/session-title/tests/service-contracts.spec.ts index 28d7e6749e..e57ce60f4d 100644 --- a/packages/session/session-title/tests/service-contracts.spec.ts +++ b/packages/session/session-title/tests/service-contracts.spec.ts @@ -1,5 +1,5 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { Context, type Fiber } from 'cordis' +import { Context, type Fiber } from '@deepseek-ai/cordis' import { describe, expect, it, vi } from 'vitest' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionTitleService, { diff --git a/packages/session/session-title/tests/session-title.spec.ts b/packages/session/session-title/tests/session-title.spec.ts index c6c3c4b9d3..bb8ce4625d 100644 --- a/packages/session/session-title/tests/session-title.spec.ts +++ b/packages/session/session-title/tests/session-title.spec.ts @@ -1,5 +1,5 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import SessionStore, { Session, SessionId } from '@deepseek-ai/dsh-session' import SessionTitleService, { diff --git a/packages/session/user-id/package.json b/packages/session/user-id/package.json index 2a09c73b0e..5ea2e7a5a9 100644 --- a/packages/session/user-id/package.json +++ b/packages/session/user-id/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/session/user-id" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/user-id/src/invariant.ts b/packages/session/user-id/src/invariant.ts index b649e23619..710714c605 100644 --- a/packages/session/user-id/src/invariant.ts +++ b/packages/session/user-id/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-user-id' diff --git a/packages/session/user-id/tests/invariant.spec.ts b/packages/session/user-id/tests/invariant.spec.ts index abffc89621..c140b73711 100644 --- a/packages/session/user-id/tests/invariant.spec.ts +++ b/packages/session/user-id/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as UserIdInvariant from '@deepseek-ai/dsh-user-id/invariant' diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index 6a9185ec2c..2ea49671fc 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-settings-local", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/settings/settings-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-atomic-write": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "@deepseek-ai/dsh-settings": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-atomic-write": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-settings": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "chokidar": "^4.0.3", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "yaml": "^2.9.0" }, "devDependencies": { @@ -41,6 +48,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/settings/settings-local/src/index.ts b/packages/settings/settings-local/src/index.ts index 16bc641f05..5a72afd99a 100644 --- a/packages/settings/settings-local/src/index.ts +++ b/packages/settings/settings-local/src/index.ts @@ -7,8 +7,8 @@ * @module @deepseek-ai/dsh-settings-local */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { watch as chokidarWatch } from 'chokidar' import { mkdir, readFile, writeFile } from 'node:fs/promises' import { dirname, extname, join, resolve } from 'node:path' diff --git a/packages/settings/settings-local/src/invariant.ts b/packages/settings/settings-local/src/invariant.ts index b59b798298..a5769dae37 100644 --- a/packages/settings/settings-local/src/invariant.ts +++ b/packages/settings/settings-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-settings-local' diff --git a/packages/settings/settings-local/tests/concurrency.spec.ts b/packages/settings/settings-local/tests/concurrency.spec.ts index 5df2179219..138e56cc7c 100644 --- a/packages/settings/settings-local/tests/concurrency.spec.ts +++ b/packages/settings/settings-local/tests/concurrency.spec.ts @@ -3,8 +3,8 @@ // neither knows the other's cache, so only the read-modify-write cycle under // the `.lock` sibling keeps both namespaces alive on disk. import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { chmod, mkdtemp, readFile, rm, utimes, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/settings/settings-local/tests/loader-composition.spec.ts b/packages/settings/settings-local/tests/loader-composition.spec.ts index c7cea89e41..f0c80e3d2d 100644 --- a/packages/settings/settings-local/tests/loader-composition.spec.ts +++ b/packages/settings/settings-local/tests/loader-composition.spec.ts @@ -11,10 +11,10 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' +import z from '@deepseek-ai/schemastery' import { settingsNamespace, type SettingsScope } from '@deepseek-ai/dsh-settings' import SettingsLocal from '../src/index.ts' diff --git a/packages/settings/settings-local/tests/local.spec.ts b/packages/settings/settings-local/tests/local.spec.ts index 8e89cd4643..e2db873764 100644 --- a/packages/settings/settings-local/tests/local.spec.ts +++ b/packages/settings/settings-local/tests/local.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { chmod, lstat, mkdir, mkdtemp, readFile, readdir, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/settings/settings-local/tests/lock-race.spec.ts b/packages/settings/settings-local/tests/lock-race.spec.ts index 46dd24353f..a5f65eab4f 100644 --- a/packages/settings/settings-local/tests/lock-race.spec.ts +++ b/packages/settings/settings-local/tests/lock-race.spec.ts @@ -1,8 +1,8 @@ // A temp-file write failure cannot be timed from outside. The `fs/promises` API // injects it once so the test can prove that the writer lock still releases. import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { access, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/settings/settings-local/tests/watcher.spec.ts b/packages/settings/settings-local/tests/watcher.spec.ts index 7b289b22a3..456bfbb87d 100644 --- a/packages/settings/settings-local/tests/watcher.spec.ts +++ b/packages/settings/settings-local/tests/watcher.spec.ts @@ -1,6 +1,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { chmod, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 09112147ec..9899ded2cc 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/settings/settings" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.18.0" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7", - "schemastery": "^3.18.0" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/settings/settings/src/index.ts b/packages/settings/settings/src/index.ts index 37d3ec1d50..cacb1c5993 100644 --- a/packages/settings/settings/src/index.ts +++ b/packages/settings/settings/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-settings */ -import { Context, Service } from 'cordis' -import type z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import type z from '@deepseek-ai/schemastery' import type { Branded } from '@deepseek-ai/dsh-brand' import { redactSecrets } from './redact.ts' import type { RedactedSecret } from './redact.ts' @@ -133,7 +133,7 @@ export interface SettingsScope { replace(section: object): Promise } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { settings: Settings } diff --git a/packages/settings/settings/src/invariant.ts b/packages/settings/settings/src/invariant.ts index d8db41bce4..d0e0344b19 100644 --- a/packages/settings/settings/src/invariant.ts +++ b/packages/settings/settings/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-settings/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { deepEqualJson } from './index.ts' diff --git a/packages/settings/settings/src/redact.ts b/packages/settings/settings/src/redact.ts index c9f4cda347..18664bcbbe 100644 --- a/packages/settings/settings/src/redact.ts +++ b/packages/settings/settings/src/redact.ts @@ -7,7 +7,7 @@ * @module @deepseek-ai/dsh-settings/redact */ -import type z from 'schemastery' +import type z from '@deepseek-ai/schemastery' /** * Minimal structural view of a live schemastery node. Only the relations the diff --git a/packages/settings/settings/tests/invariant.spec.ts b/packages/settings/settings/tests/invariant.spec.ts index 0976827368..179658e3ef 100644 --- a/packages/settings/settings/tests/invariant.spec.ts +++ b/packages/settings/settings/tests/invariant.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import InvariantService from '@deepseek-ai/dsh-invariants' import * as SettingsInvariant from '../src/invariant.ts' import { settingsNamespace } from '../src/index.ts' diff --git a/packages/settings/settings/tests/redact.spec.ts b/packages/settings/settings/tests/redact.spec.ts index fff6902ea6..dbf02e8ba8 100644 --- a/packages/settings/settings/tests/redact.spec.ts +++ b/packages/settings/settings/tests/redact.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { redactSecrets, settingsNamespace } from '../src/index.ts' import { MemorySettings } from './memory.ts' diff --git a/packages/settings/settings/tests/settings.spec.ts b/packages/settings/settings/tests/settings.spec.ts index fb0efe1ff8..154f7ace4e 100644 --- a/packages/settings/settings/tests/settings.spec.ts +++ b/packages/settings/settings/tests/settings.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import z from 'schemastery' +import { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { Settings, SettingsConflictError, deepEqualJson, installSettingsSection, settingsNamespace, type SettingsNamespace, type SettingsScope, type SettingsUpdateSource } from '../src/index.ts' import { MemorySettings } from './memory.ts' diff --git a/packages/skill/skill-badge/assets/dsh-badge.md b/packages/skill/skill-badge/assets/dsh-badge.md index 5a789fac2f..9f15ed4d54 100644 --- a/packages/skill/skill-badge/assets/dsh-badge.md +++ b/packages/skill/skill-badge/assets/dsh-badge.md @@ -6,14 +6,14 @@ Add the official “powered by dsh” badge without recreating or restyling it. - Local PNG: [`dsh-badge.png`](dsh-badge.png), 726×120 source image; render at 121×20 - Shields.io image URL: `https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white` -- Project URL: `https://github.com/deepseek-ai/deepseek-harness-sdk` +- Project URL: `https://github.com/deepseek-ai/deepseek-harness` ## Markdown Use this linked badge in Markdown: ```markdown -[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness-sdk) +[![](https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white)](https://github.com/deepseek-ai/deepseek-harness) ``` If attribution should not be linked, use: diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index b9dc53d9a5..c3c79538c2 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/skill/skill-badge" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,13 +32,13 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-skill": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/skill/skill-badge/src/index.ts b/packages/skill/skill-badge/src/index.ts index 9cff2070fb..753d51f817 100644 --- a/packages/skill/skill-badge/src/index.ts +++ b/packages/skill/skill-badge/src/index.ts @@ -6,7 +6,7 @@ import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { BUNDLED_SKILL_RANK, type SkillCandidate, diff --git a/packages/skill/skill-badge/src/invariant.ts b/packages/skill/skill-badge/src/invariant.ts index c087d5917f..7f36b07a7d 100644 --- a/packages/skill/skill-badge/src/invariant.ts +++ b/packages/skill/skill-badge/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill-badge' diff --git a/packages/skill/skill-badge/tests/skill-badge.spec.ts b/packages/skill/skill-badge/tests/skill-badge.spec.ts index e4d62f1c89..1da2765a38 100644 --- a/packages/skill/skill-badge/tests/skill-badge.spec.ts +++ b/packages/skill/skill-badge/tests/skill-badge.spec.ts @@ -1,7 +1,7 @@ import { createHash } from 'node:crypto' import { readFile } from 'node:fs/promises' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import SkillService from '@deepseek-ai/dsh-skill' import * as SkillBadge from '@deepseek-ai/dsh-skill-badge' diff --git a/packages/skill/skill-local/README.i18n.yaml b/packages/skill/skill-local/README.i18n.yaml index ed7a501145..38d25a6d7c 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: aa25278750b5a1577eb567e50344fb3af425d71a -README.zh.md: 59abd5623da189d0b5d739eec56e034b553690b9 +README.md: 877b784353998a191a2d1a377aaf5406e1a97965 +README.zh.md: e6f07b2f630ecd1e6261f32c6e58254c14bb450c diff --git a/packages/skill/skill-local/README.md b/packages/skill/skill-local/README.md index aa25278750..877b784353 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 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. +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 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 59abd5623d..e6f07b2f63 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` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方,例如不可变的仓库插件。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 +项目根目录是包含 `.git` 的最近祖先目录;如果不存在,则使用当前 cwd。用户 DSH 根目录会跳过其 `.system` 子目录,因此归系统所有的目录不会被当作普通用户 skill。`includeDefaultRoots: false` 会省略项目根、用户根以及 `$DSH_BUNDLED_SKILL_DIR` 环境默认值,同时保留显式配置的自定义根与 bundled 根,因此可以挂载多个只看到自身根的唯一命名隔离提供方。该提供方提供项目和用户 skill;其他提供方可提供内置系统 skill。 当 `ctx.fs` 可用时,发现通过 `ctx.fs.listDir` 列出根,通过 `ctx.fs.readText` 读取 skill 文件,并通过文件系统服务探测 `.git`。完整 skill 加载会将查找中止信号转发给文件系统元数据和内容读取。如果没有文件系统服务,提供方回退到可中止的 Node 文件系统 I/O,使最小本地上下文仍能加载 skill。已确认缺失的路径属于有效空状态;遇到格式错误或非文本条目时,提供方会发出警告并跳过;意外的发现或读取失败会使注册表快照不完整,系统不会因此用看似发生删除的结果替换上一份可用模型目录。 diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index 25306774b9..c9f3ba8f27 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-skill-local", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/skill/skill-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-paths": "^0.0.1", - "@deepseek-ai/dsh-skill": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-fs": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-paths": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "chokidar": "^5.0.0", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "yaml": "^2.4.2" }, "devDependencies": { @@ -41,6 +48,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/skill/skill-local/src/index.ts b/packages/skill/skill-local/src/index.ts index 996a12329f..190a38c8ee 100644 --- a/packages/skill/skill-local/src/index.ts +++ b/packages/skill/skill-local/src/index.ts @@ -13,10 +13,10 @@ import { access, lstat, readdir, readFile, stat } from 'node:fs/promises' import { unwatchFile, watchFile, type Stats } from 'node:fs' import { dirname, isAbsolute, join, relative, resolve, sep } from 'node:path' import { homedir } from 'node:os' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import chokidar from 'chokidar' -import z from 'schemastery' -import type Schema from 'schemastery' +import z from '@deepseek-ai/schemastery' +import type Schema from '@deepseek-ai/schemastery' import { parse as parseYaml } from 'yaml' import type { FileSystem, FsDirEntry, FsTarget } from '@deepseek-ai/dsh-fs' import { canonicalizeWatchPath, resolveDshHome } from '@deepseek-ai/dsh-paths' @@ -166,9 +166,8 @@ export class LocalSkillProvider implements SkillProvider { this.watchManager = new SkillWatchManager(ctx, control.invalidate, resolveWatchConfig(config)) control.signal.addEventListener('abort', () => { void this.dispose() }, { once: true }) // 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. + // must see only its explicit roots, or every such provider would + // re-discover the app's bundled skills 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/src/invariant.ts b/packages/skill/skill-local/src/invariant.ts index 6d4917a4d9..37ddd64c95 100644 --- a/packages/skill/skill-local/src/invariant.ts +++ b/packages/skill/skill-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill-local' diff --git a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts index 6aa0ef5ca4..dc4d954272 100644 --- a/packages/skill/skill-local/tests/skill-local-watcher.spec.ts +++ b/packages/skill/skill-local/tests/skill-local-watcher.spec.ts @@ -4,7 +4,7 @@ import { mkdir, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' import { beforeEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SkillService from '@deepseek-ai/dsh-skill' interface FakeWatcherControl { diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 8bb08d2947..a472b4dbe1 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { mkdir, readdir, readFile, rename, rm, stat, symlink, writeFile } from 'node:fs/promises' import { dirname, join } from 'node:path' import { tmpdir } from 'node:os' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SkillService from '@deepseek-ai/dsh-skill' import { FileSystem, FsError, FsVersion, type FsDirEntry, type FsEditOutcome, type FsEditRequest, type FsInfo, type FsPathInfo, type FsTarget, type FsWriteOutcome } from '@deepseek-ai/dsh-fs' import * as SkillLocal from '../src/index.ts' @@ -96,6 +96,10 @@ class TestFileSystem extends FileSystem { throw new Error('not needed in skill tests') } + override async readBytes(_target: FsTarget, _signal: AbortSignal | undefined, _maxBytes: number): Promise { + throw new Error('not needed in skill tests') + } + override async listDir(target: FsTarget): Promise { this.listDirCalls += 1 if (this.failListDirPaths.has(target.displayPath)) throw new Error('list temporarily failed') @@ -830,7 +834,7 @@ describe('LocalSkillProvider', () => { // 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. + // drop it — isolated providers never re-claim the app's builtins. const isolated = new Context() await isolated.plugin(SkillService) const customOnly = join(envHome, 'custom-only') diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index da51610ec3..dbb4429d9d 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/skill/skill" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,18 +32,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/skill/skill/src/index.ts b/packages/skill/skill/src/index.ts index c013933547..f5ba597820 100644 --- a/packages/skill/skill/src/index.ts +++ b/packages/skill/skill/src/index.ts @@ -10,12 +10,12 @@ * @module @deepseek-ai/dsh-skill */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { assertNever } from '@deepseek-ai/dsh-llm' import { NamedEntries, ScopedLayers, scopeChainOf, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeKey, ScopeLayer } from '@deepseek-ai/dsh-scope' -import z from 'schemastery' -import type Schema from 'schemastery' +import z from '@deepseek-ai/schemastery' +import type Schema from '@deepseek-ai/schemastery' const SKILL_NAME = /^[a-z0-9]+(?:-[a-z0-9]+)*$/ const DEFAULT_COLLECT_CACHE_ENTRIES = 128 @@ -281,7 +281,7 @@ export interface Config { readonly collectCacheMaxEntries?: number } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { skills: SkillService } diff --git a/packages/skill/skill/src/invariant.ts b/packages/skill/skill/src/invariant.ts index 5145dee6da..b047a4476e 100644 --- a/packages/skill/skill/src/invariant.ts +++ b/packages/skill/skill/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-skill' diff --git a/packages/skill/skill/tests/skill.spec.ts b/packages/skill/skill/tests/skill.spec.ts index a5e03b610a..7dae5f5cf6 100644 --- a/packages/skill/skill/tests/skill.spec.ts +++ b/packages/skill/skill/tests/skill.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { bindScopeParent, createScope, scopeOf } from '@deepseek-ai/dsh-scope' import SkillService, { isModelInvocable, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index e3ff7520b5..7712261549 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/skill/tool-skill" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-skill": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-skill": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -44,6 +51,6 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/skill/tool-skill/src/index.ts b/packages/skill/tool-skill/src/index.ts index 634fb7ce02..e2a0cc2dca 100644 --- a/packages/skill/tool-skill/src/index.ts +++ b/packages/skill/tool-skill/src/index.ts @@ -5,8 +5,8 @@ */ import { createHash } from 'node:crypto' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent, PreStepDecision } from '@deepseek-ai/dsh-agent' import { defineTool } from '@deepseek-ai/dsh-tools' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/skill/tool-skill/src/invariant.ts b/packages/skill/tool-skill/src/invariant.ts index 68d70fa2d2..770edf99e5 100644 --- a/packages/skill/tool-skill/src/invariant.ts +++ b/packages/skill/tool-skill/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-skill' diff --git a/packages/skill/tool-skill/tests/tool-skill.spec.ts b/packages/skill/tool-skill/tests/tool-skill.spec.ts index 496fe81d46..f4e5ab9f8e 100644 --- a/packages/skill/tool-skill/tests/tool-skill.spec.ts +++ b/packages/skill/tool-skill/tests/tool-skill.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { mkdir, writeFile } from 'node:fs/promises' import { join } from 'node:path' import { tmpdir } from 'node:os' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, type Message } from '@deepseek-ai/dsh-llm' import { createScope, type Scope } from '@deepseek-ai/dsh-scope' import { Session, SessionId, type SessionEvent, type UserMessage } from '@deepseek-ai/dsh-session' diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index f185ab2c11..1bab4c650f 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-spill-local", "description": "Local-filesystem implementation of the DeepSeek Harness spill storage seam (private session-scoped files)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/spill/spill-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,12 +32,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-spill": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-spill": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -38,6 +45,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/spill/spill-local/src/index.ts b/packages/spill/spill-local/src/index.ts index 73e2cad851..54e2e6cd6d 100644 --- a/packages/spill/spill-local/src/index.ts +++ b/packages/spill/spill-local/src/index.ts @@ -8,9 +8,9 @@ * @module @deepseek-ai/dsh-spill-local */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { resolve } from 'node:path' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' import type { SaveTextSpill, SpillRef } from '@deepseek-ai/dsh-spill' import { privateRoot, saveTextFile } from './store.ts' diff --git a/packages/spill/spill-local/src/invariant.ts b/packages/spill/spill-local/src/invariant.ts index 4b44ddbebf..2651172635 100644 --- a/packages/spill/spill-local/src/invariant.ts +++ b/packages/spill/spill-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill-local' diff --git a/packages/spill/spill-local/tests/spill-local.spec.ts b/packages/spill/spill-local/tests/spill-local.spec.ts index 3c6f9ac82d..fd01babeff 100644 --- a/packages/spill/spill-local/tests/spill-local.spec.ts +++ b/packages/spill/spill-local/tests/spill-local.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it, beforeEach, afterEach } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { mkdtempSync, readFileSync, rmSync, statSync } from 'node:fs' import { tmpdir } from 'node:os' import { basename, dirname, isAbsolute, join, normalize } from 'node:path' diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index f238acd3d6..cadec495fb 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-spill-policy", "description": "Tool-result spill policy for the DeepSeek Harness — replaces oversized plain-text tool results with a retained preview plus a spill-file path (no service surface)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/spill/spill-policy" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-retention": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-spill": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-retention": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-spill": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -45,6 +52,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/spill/spill-policy/src/index.ts b/packages/spill/spill-policy/src/index.ts index 45d7ba2e74..ca71dd8588 100644 --- a/packages/spill/spill-policy/src/index.ts +++ b/packages/spill/spill-policy/src/index.ts @@ -43,8 +43,8 @@ * @module @deepseek-ai/dsh-spill-policy */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { TextRetainer, describeOmitted } from '@deepseek-ai/dsh-retention' import type { Omitted } from '@deepseek-ai/dsh-retention' diff --git a/packages/spill/spill-policy/src/invariant.ts b/packages/spill/spill-policy/src/invariant.ts index 82a4bee211..860b4c5187 100644 --- a/packages/spill/spill-policy/src/invariant.ts +++ b/packages/spill/spill-policy/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill-policy' diff --git a/packages/spill/spill-policy/tests/spill-policy.spec.ts b/packages/spill/spill-policy/tests/spill-policy.spec.ts index 0d35927cab..ff8afa22dd 100644 --- a/packages/spill/spill-policy/tests/spill-policy.spec.ts +++ b/packages/spill/spill-policy/tests/spill-policy.spec.ts @@ -9,8 +9,8 @@ */ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { createUserMessage, CallId } from '@deepseek-ai/dsh-llm' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 6a87d575a3..c3379e6f72 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-spill", "description": "Abstract spill storage seam (ctx.spillStore) for the DeepSeek Harness — save oversized tool text and return a retrieval locator", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/spill/spill" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/spill/spill/src/index.ts b/packages/spill/spill/src/index.ts index 0d98ea9f66..ed0f8d33eb 100644 --- a/packages/spill/spill/src/index.ts +++ b/packages/spill/spill/src/index.ts @@ -14,13 +14,13 @@ * @module @deepseek-ai/dsh-spill */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { SaveTextSpill, SpillRef } from './types.ts' export { SpillLocator } from './types.ts' export type { SaveTextSpill, SpillOwner, SpillRef, SpillSource } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { spillStore: SpillStore } diff --git a/packages/spill/spill/src/invariant.ts b/packages/spill/spill/src/invariant.ts index 5011ac1d52..34e39d2e1d 100644 --- a/packages/spill/spill/src/invariant.ts +++ b/packages/spill/spill/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-spill' diff --git a/packages/spill/spill/tests/service.spec.ts b/packages/spill/spill/tests/service.spec.ts index 141072d67a..c17a77b273 100644 --- a/packages/spill/spill/tests/service.spec.ts +++ b/packages/spill/spill/tests/service.spec.ts @@ -6,7 +6,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import { SpillLocator, SpillStore } from '@deepseek-ai/dsh-spill' diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index aaa277b244..064ec4aa8a 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-storage-domain", "description": "Domain data form (ctx.storage.domain): schema-validated, event-emitting KV domains over storage backends for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/storage/storage-domain" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-storage": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/storage/storage-domain/src/domain.ts b/packages/storage/storage-domain/src/domain.ts index 26ed726b93..c4087d6dd2 100644 --- a/packages/storage/storage-domain/src/domain.ts +++ b/packages/storage/storage-domain/src/domain.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-storage-domain/src/domain */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { KvUnit } from '@deepseek-ai/dsh-storage' import { DomainError } from './error.ts' import type { DomainSpec, DomainGlobalSpec, TableKeyOf, TableValueOf } from './spec.ts' diff --git a/packages/storage/storage-domain/src/events.ts b/packages/storage/storage-domain/src/events.ts index f70095e5d5..610182d07a 100644 --- a/packages/storage/storage-domain/src/events.ts +++ b/packages/storage/storage-domain/src/events.ts @@ -33,7 +33,7 @@ export interface DomainChangedDeleted extends DomainChangedBase { /** One durable domain change; a closed union — switch on `operation`. */ export type DomainChanged = DomainChangedPut | DomainChangedDeleted -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { /** * A domain record or the global singleton changed, emitted once per write diff --git a/packages/storage/storage-domain/src/index.ts b/packages/storage/storage-domain/src/index.ts index cb6c5f0f77..d2c16a3d69 100644 --- a/packages/storage/storage-domain/src/index.ts +++ b/packages/storage/storage-domain/src/index.ts @@ -7,8 +7,8 @@ * @module @deepseek-ai/dsh-storage-domain */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import { DomainError } from './error.ts' import { descriptorOf } from './spec.ts' @@ -32,7 +32,7 @@ declare module '@deepseek-ai/dsh-storage' { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { storageDomain: DomainFacility } diff --git a/packages/storage/storage-domain/src/invariant.ts b/packages/storage/storage-domain/src/invariant.ts index b2da8bebe4..5386149115 100644 --- a/packages/storage/storage-domain/src/invariant.ts +++ b/packages/storage/storage-domain/src/invariant.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-storage-domain/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { DomainChanged } from './events.ts' diff --git a/packages/storage/storage-domain/tests/domain.spec.ts b/packages/storage/storage-domain/tests/domain.spec.ts index 4a083b3f78..5e1afbc863 100644 --- a/packages/storage/storage-domain/tests/domain.spec.ts +++ b/packages/storage/storage-domain/tests/domain.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import { apply, DomainFacility, defineDomain, domainTable } from '../src/index.ts' diff --git a/packages/storage/storage-domain/tests/invariant.spec.ts b/packages/storage/storage-domain/tests/invariant.spec.ts index 80c7264aae..9efe42c108 100644 --- a/packages/storage/storage-domain/tests/invariant.spec.ts +++ b/packages/storage/storage-domain/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import Storage from '@deepseek-ai/dsh-storage' import InvariantService, { InvariantError } from '@deepseek-ai/dsh-invariants' diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index 4dc9bec40b..78e1d9231a 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/storage/storage-json" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-storage": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/storage/storage-json/src/index.ts b/packages/storage/storage-json/src/index.ts index c2c0ac0dd8..69c42cd194 100644 --- a/packages/storage/storage-json/src/index.ts +++ b/packages/storage/storage-json/src/index.ts @@ -7,8 +7,8 @@ import { mkdir } from 'node:fs/promises' import { join } from 'node:path' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage' import { openJsonUnit } from './unit.ts' diff --git a/packages/storage/storage-json/src/invariant.ts b/packages/storage/storage-json/src/invariant.ts index 3f3ec4a2d1..915a898e8f 100644 --- a/packages/storage/storage-json/src/invariant.ts +++ b/packages/storage/storage-json/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-storage-json' diff --git a/packages/storage/storage-json/tests/json-backend.spec.ts b/packages/storage/storage-json/tests/json-backend.spec.ts index b8d37eabb9..727981b49b 100644 --- a/packages/storage/storage-json/tests/json-backend.spec.ts +++ b/packages/storage/storage-json/tests/json-backend.spec.ts @@ -2,7 +2,7 @@ import { mkdir, mkdtemp, readFile, rename, rm, writeFile } from 'node:fs/promise import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterAll, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import Storage, { storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import InvariantService from '@deepseek-ai/dsh-invariants' import { runKvBackendContract } from '../../storage/tests/contract.ts' diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index cebde3d279..2fef41cebd 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/storage/storage-sqlite" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-storage": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/storage/storage-sqlite/src/index.ts b/packages/storage/storage-sqlite/src/index.ts index eff5bb80fb..5cd59cadc2 100644 --- a/packages/storage/storage-sqlite/src/index.ts +++ b/packages/storage/storage-sqlite/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-storage-sqlite */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { DatabaseSync } from 'node:sqlite' import { StorageError, UNIT_NAME_RE, storageBackendServiceKey } from '@deepseek-ai/dsh-storage' import type { KvFacet, KvUnit, KvUnitDescriptor, StorageBackend } from '@deepseek-ai/dsh-storage' diff --git a/packages/storage/storage-sqlite/src/invariant.ts b/packages/storage/storage-sqlite/src/invariant.ts index cbfadc8442..1775bd9524 100644 --- a/packages/storage/storage-sqlite/src/invariant.ts +++ b/packages/storage/storage-sqlite/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-storage-sqlite' diff --git a/packages/storage/storage-sqlite/tests/invariant.spec.ts b/packages/storage/storage-sqlite/tests/invariant.spec.ts index 0c23906ca4..3255549a0c 100644 --- a/packages/storage/storage-sqlite/tests/invariant.spec.ts +++ b/packages/storage/storage-sqlite/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as StorageSqliteInvariant from '../src/invariant.ts' diff --git a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts index 37cf7ba122..f2fd65ee42 100644 --- a/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts +++ b/packages/storage/storage-sqlite/tests/sqlite-backend.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { chmod, mkdtemp, rm, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 1c78c1d434..39c8f0bed3 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-storage", "description": "Storage hub (ctx.storage): named backend registry plus mounted data-form facilities for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/storage/storage" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/storage/storage/src/index.ts b/packages/storage/storage/src/index.ts index 5312513591..9344558e09 100644 --- a/packages/storage/storage/src/index.ts +++ b/packages/storage/storage/src/index.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-storage */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { StorageError } from './error.ts' import { BackendRegistry } from './registry.ts' @@ -27,7 +27,7 @@ export function storageBackendServiceKey(name: string): string { return `storage.backend.${name}` } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { storage: Storage } diff --git a/packages/storage/storage/src/invariant.ts b/packages/storage/storage/src/invariant.ts index cac811a39a..1c303c43fa 100644 --- a/packages/storage/storage/src/invariant.ts +++ b/packages/storage/storage/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-storage' diff --git a/packages/storage/storage/tests/registry.spec.ts b/packages/storage/storage/tests/registry.spec.ts index 232efc3640..c23a9a36c7 100644 --- a/packages/storage/storage/tests/registry.spec.ts +++ b/packages/storage/storage/tests/registry.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import Storage, { BackendRegistry, storageBackendServiceKey } from '../src/index.ts' import type { StorageBackend } from '../src/index.ts' diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 424cb9cb5c..5efd65bcd5 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-subagent-acp", "description": "Out-of-process ACP subagent backend: drives a child agent in a spawned subprocess over the Agent Client Protocol", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subagent/subagent-acp" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,21 +32,21 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@agentclientprotocol/sdk": "0.25.1", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -49,6 +56,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-acp/src/index.ts b/packages/subagent/subagent-acp/src/index.ts index af126b86de..4b526279ba 100644 --- a/packages/subagent/subagent-acp/src/index.ts +++ b/packages/subagent/subagent-acp/src/index.ts @@ -9,8 +9,8 @@ import { accessSync, constants, statSync } from 'node:fs' import { isAbsolute, resolve } from 'node:path' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ResolvedSubagentStartRequest, SubagentCapabilities, diff --git a/packages/subagent/subagent-acp/src/invariant.ts b/packages/subagent/subagent-acp/src/invariant.ts index 85c1601348..536404e6d9 100644 --- a/packages/subagent/subagent-acp/src/invariant.ts +++ b/packages/subagent/subagent-acp/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-acp' diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 7c82bfe9fb..38329244ba 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -24,6 +24,7 @@ import { } from '@agentclientprotocol/sdk' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' +import { AssistantOutputFold } from '@deepseek-ai/dsh-subagent' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' @@ -232,8 +233,9 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe let processDisposal: Promise | undefined const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs)) - // Accumulate the child's streamed assistant text — the SubagentResult output. - const output: string[] = [] + // ACP exposes no complete assistant messages, so the shared fold selects its + // accumulated assistant text. + const fold = new AssistantOutputFold() // Shared mutable state keeps cancellation visible across async closures. const flags = { cancelled: false } @@ -241,7 +243,7 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe sessionUpdate(params: SessionNotification): Promise { const update = params.update if (update.sessionUpdate === 'agent_message_chunk') { - output.push(acpContentText(update.content)) + fold.pushText(acpContentText(update.content)) } // Other updates (thoughts, tool calls, plans) are consumed but not // surfaced — the subagent returns only its final answer. @@ -284,13 +286,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe const onAbort = (): void => { requestCancel() } request.signal.addEventListener('abort', onAbort, { once: true }) - // The accumulated child text as harness ContentBlocks (empty array when the - // child streamed nothing). Read at every return so a partial answer survives - // a later cancel/error. - const collectOutput = (): ContentBlock[] => { - const text = output.join('') - return text.length > 0 ? [{ type: 'text', text }] : [] - } + // Read at every return so a partial answer survives a later cancel/error. + const collectOutput = (): ContentBlock[] => fold.collect() ?? [] // Establish the remote session before publishing a handle. Any failure owns // the still-private process and therefore reaps it before rejecting. diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts index 9216068b07..cacee70a2a 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.e2e.ts @@ -3,7 +3,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts index 122d781b6c..291e02c32a 100644 --- a/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts +++ b/packages/subagent/subagent-acp/tests/subagent-acp.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { chmodSync, existsSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join, resolve } from 'node:path' diff --git a/packages/subagent/subagent-claude-code/README.i18n.yaml b/packages/subagent/subagent-claude-code/README.i18n.yaml index 23450e6e42..bbb3c9cf1a 100644 --- a/packages/subagent/subagent-claude-code/README.i18n.yaml +++ b/packages/subagent/subagent-claude-code/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/subagent/subagent-claude-code/README.md -README.md: 222cf796f71f8dc0dc2c06f7f32bab70ded6ab43 -README.zh.md: 9334820a591cbfcb8dc2046dc3a78201ba193aab +README.md: 17b14e847baea3eadda7129b5e49f5e65b668cc8 +README.zh.md: 2f59144d5bd9f26a58773e6dd53909b2b0e8da14 diff --git a/packages/subagent/subagent-claude-code/README.md b/packages/subagent/subagent-claude-code/README.md index 222cf796f7..17b14e847b 100644 --- a/packages/subagent/subagent-claude-code/README.md +++ b/packages/subagent/subagent-claude-code/README.md @@ -2,7 +2,7 @@ English | [中文](README.zh.md) -This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, starts the SDK-distributed Claude Code CLI through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. +This package registers the fixed `claude-code` subagent provider. Each accepted run invokes the official Claude Agent SDK in the delegating Session's workspace, resolves the native `claude` executable through the shared subprocess service, submits one self-contained text task, and returns only the final answer through the shared [`dsh-subagent`](../subagent/README.md) result contract. ## Start and ownership @@ -29,9 +29,9 @@ The provider advertises no optional start-time capabilities and reports `inherit | `env` | `{}` | Explicit SDK/CLI environment layered over the shared credential-scrubbed parent environment. | | `disposeGraceMs` | `3000` | Positive finite grace in milliseconds, no greater than [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md), between the shared process-tree owner's termination tiers; disposal then waits for whole-tree exit. | -Production uses the Claude Code CLI supplied by `@anthropic-ai/claude-agent-sdk` and the host's native settings and authentication. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. +Production resolves `claude` from the subprocess execution world's credential-scrubbed `PATH`, with explicit `env` entries applied, and passes the resulting path to the SDK as `pathToClaudeCodeExecutable`. On Windows, a resolved `.cmd` or `.bat` path is carried as a quoted, per-spawn environment value that `cmd.exe /v:off` expands once, so valid path metacharacters remain data. The pinned SDK's fixed flags then occupy cmd's command tail and contain no cmd metacharacters; they are not ordinary Windows argv. Native settings and authentication remain authoritative. The plugin does not install another CLI, select a model, create a product home, log in, or probe an account. Credential-shaped ambient variables are removed before the explicit `env` overlay is applied, so an API key or token intended for the child must be supplied there. Non-credential endpoint variables such as `ANTHROPIC_BASE_URL`, along with ordinary ambient values such as `PATH` and `HOME`, remain inherited unless overridden. -Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_claude_code` by default. +Shipped profiles load this provider once on the host and start no Claude process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_claude_code` only to agents composed from the copy. A custom host composition can still use both rows directly. ```yaml - id: subagent-claude-code @@ -42,6 +42,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: claude-code toolName: subagent_claude_code @@ -51,7 +52,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe ## Product compatibility and evidence -The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`, whose platform optional dependency supplies Claude Code 2.1.220. Required evidence exercises that official distribution through a keyless loopback product path and a credentialed DeepSeek path, while Loader composition proves that both opt-in product packages coexist without starting either product. +The runtime dependency is pinned to `@anthropic-ai/claude-agent-sdk@0.3.220`. Production runs the native `claude` installation. The keyless real-product test uses the SDK-distributed Claude Code 2.1.220 CLI as a deterministic fixture, routed through the same native executable-resolution and Windows batch-shim path; it does not claim compatibility with every independently installed version. Loader composition proves that both product packages coexist without starting either product. The project owner's identity-scoped distribution authorization covers the official SDK and the official CLI/platform payloads declared by each SDK version. [`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) discloses the current optional payload closure without classifying its declared terms as permissive; unrelated non-permissive runtime dependencies continue to fail the notices gate. @@ -89,7 +90,8 @@ Append-only: the new tool result follows the reusable parent request prefix. - **One fresh query and process per run** — there is no continuation, resume, pooling, progress stream, or product-session persistence. - **Host settings are intentionally authoritative** — project and user settings can change model, tools, and behavior; the provider does not provide a filtered or hermetic production mode. -- **Product installation and account state remain native** — an incompatible SDK payload, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **Product installation and account state remain native** — a missing or incompatible `claude`, configuration error, or authentication failure is surfaced as a startup or run error; the plugin provides no installer or login flow. +- **The SDK platform CLI remains in the install closure** — production ignores it in favor of the host `claude`, but the current SDK optional dependency is still installed and supplies the keyless compatibility fixture. Removing that payload belongs to the separate product installation-closure follow-up. - **No human interaction path** — `AskUserQuestion` is disabled and other interactive callbacks are absent, so tasks requiring new approval or input fail instead of suspending. - **Final text only** — reasoning, intermediate messages, tool traffic, usage, stderr, and workspace diffs remain product-local. - **No optional shared capabilities** — output schemas, child personas, tool filtering, and harness depth enforcement are rejected by the shared service for this provider. diff --git a/packages/subagent/subagent-claude-code/README.zh.md b/packages/subagent/subagent-claude-code/README.zh.md index 9334820a59..2f59144d5b 100644 --- a/packages/subagent/subagent-claude-code/README.zh.md +++ b/packages/subagent/subagent-claude-code/README.zh.md @@ -2,7 +2,7 @@ [English](README.md) | 中文 -本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务启动 SDK 分发的 Claude Code CLI,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 +本包(package)注册固定的 `claude-code` subagent 提供方。每次接受运行请求后,它都会在发起委托的会话工作区中调用官方 Claude Agent SDK,通过共享子进程服务解析原生 `claude` 可执行文件,提交一个自包含的文本任务,并通过共享的 [`dsh-subagent`](../subagent/README.md) 结果约定仅返回最终答案。 ## 启动与所有权 @@ -29,9 +29,9 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK | `env` | `{}` | 显式指定的 SDK/CLI 环境,叠加在由共享机制清除凭证后的父环境之上。 | | `disposeGraceMs` | `3000` | 共享进程树责任方各终止层级之间的宽限期,单位为毫秒且须为正有限值,并不得大于仓库共享的 [`MAX_TIMER_DELAY_MS`](../../util/timeout/README.md);随后资源释放会等待整棵进程树退出。 | -生产环境使用 `@anthropic-ai/claude-agent-sdk` 提供的 Claude Code CLI,以及宿主机原生设置与身份验证。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 +生产环境从子进程执行世界清除凭证后的 `PATH` 解析 `claude`,再应用显式 `env` 条目,并把所得路径作为 `pathToClaudeCodeExecutable` 交给 SDK。在 Windows 上,解析到的 `.cmd` 或 `.bat` 路径会作为带引号、仅供本次 spawn 使用的环境值交给 `cmd.exe /v:off` 展开一次,因此合法路径中的元字符仍只是数据。锁定版本的 SDK 随后把固定命令行选项放在 cmd 的命令尾部;这些选项不含 cmd 元字符,也并不是普通的 Windows argv。原生设置与身份验证继续是权威来源。本插件不安装另一份 CLI、不选择模型、不创建产品主目录、不执行登录,也不探测账户。具有凭证特征的环境变量会在显式 `env` 覆盖生效前被清除,因此供子进程使用的 API 密钥或 token 必须在该配置中显式提供。除非被覆盖,`ANTHROPIC_BASE_URL` 等非凭证端点变量以及 `PATH` 和 `HOME` 等普通环境变量仍会被继承。 -请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_claude_code`。 +随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Claude 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_claude_code`。自定义宿主组装仍可直接使用两条配置行。 ```yaml - id: subagent-claude-code @@ -42,6 +42,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK - id: tool-subagent-claude-code name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: claude-code toolName: subagent_claude_code @@ -51,7 +52,7 @@ SDK 接收由文本块原样拼接成的任务。提供方会完整迭代 SDK ## 产品兼容性与证据 -运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`,其平台可选依赖提供 Claude Code 2.1.220。强制证据会通过无密钥回环产品路径与带密钥 DeepSeek 路径运行该官方发行版,而 Loader 组合则证明两个选择启用的产品包能够共存,且不会启动任一产品。 +运行时依赖精确锁定为 `@anthropic-ai/claude-agent-sdk@0.3.220`。生产运行使用原生 `claude` 安装。无密钥真实产品测试使用由 SDK 分发的 Claude Code 2.1.220 CLI 作为确定性 fixture(测试前置数据),并通过同一套原生可执行文件解析路径与 Windows batch shim 路径运行;这项测试不声称兼容每个独立安装的版本。Loader 组合证明两个产品包能够共存且不会启动任一产品。 项目所有者按身份范围授权分发官方 SDK 及每个 SDK 版本声明的官方 CLI/平台载荷。[`THIRD_PARTY_NOTICES.md`](../../../THIRD_PARTY_NOTICES.md) 会披露当前可选载荷闭包,但不会把其声明条款归类为宽松许可证;其他无关的非宽松运行时依赖仍会使第三方声明门禁失败。 @@ -89,7 +90,8 @@ Claude Code 子任务会在一个全新的 SDK query 中接收独立文本任务 - **每次运行均新建一个 query 和一个进程**:不支持续接、恢复、池化、进度流或产品会话持久化。 - **宿主设置有意保持权威**:项目和用户设置可以改变模型、工具与行为;本提供方不提供经过筛选或与宿主环境隔离的生产模式。 -- **产品安装与账户状态仍由原生机制管理**:不兼容的 SDK 载荷、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **产品安装与账户状态仍由原生机制管理**:`claude` 缺失或不兼容、配置错误或身份验证失败都会呈现为启动错误或运行错误;本插件不提供安装程序或登录流程。 +- **SDK 平台 CLI 仍在安装闭包内**:生产环境会忽略它,改用宿主提供的 `claude`,但当前 SDK 的可选依赖仍会安装,并提供无密钥兼容性 fixture。移除该载荷属于独立的产品安装闭包后续项。 - **没有人工交互路径**:`AskUserQuestion` 被禁用,其他交互回调也不存在,因此需要新审批或输入的任务会失败而不会挂起。 - **仅返回最终文本**:推理、中间消息、工具通信、用量信息、stderr 和工作区差异仍只保留在产品内部。 - **没有可选的共享能力**:对于本提供方,共享服务会拒绝输出 schema、子任务角色设定、工具筛选和 harness 深度强制约束。 diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 8b25c6919c..4767d1ccb8 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subagent/subagent-claude-code" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,18 +32,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@anthropic-ai/sdk": "0.93.0", "@anthropic-ai/claude-agent-sdk": "0.3.220", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -48,6 +55,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-claude-code/src/index.ts b/packages/subagent/subagent-claude-code/src/index.ts index e4d6fbac5f..ccd150b746 100644 --- a/packages/subagent/subagent-claude-code/src/index.ts +++ b/packages/subagent/subagent-claude-code/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-subagent-claude-code */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { assertPositiveFinite, @@ -59,19 +59,25 @@ class ClaudeCodeProvider implements SubagentProvider { private readonly config: ResolvedConfig, ) {} - start(request: ResolvedSubagentStartRequest) { + async start(request: ResolvedSubagentStartRequest) { const parentCwd = request.parent.session.header.cwd if (parentCwd === undefined) { throw new Error( 'subagent-claude-code: no working directory for the child — delegate from a parent session that has one', ) } + const executable = await this.ctx.subprocess.resolveExecutable( + 'claude', + this.config.env, + request.signal, + ) const spec: ClaudeCodeRunSpec = { cwd: resolveChildCwd( 'subagent-claude-code', undefined, parentCwd, ), + executable, env: this.config.env, disposeGraceMs: this.config.disposeGraceMs, spawn: spawnSpec => this.ctx.subprocess.spawn(spawnSpec), diff --git a/packages/subagent/subagent-claude-code/src/invariant.ts b/packages/subagent/subagent-claude-code/src/invariant.ts index 462692590f..44fa400e16 100644 --- a/packages/subagent/subagent-claude-code/src/invariant.ts +++ b/packages/subagent/subagent-claude-code/src/invariant.ts @@ -5,7 +5,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-claude-code' diff --git a/packages/subagent/subagent-claude-code/src/process.ts b/packages/subagent/subagent-claude-code/src/process.ts index 32a545bf08..1e2a259ca2 100644 --- a/packages/subagent/subagent-claude-code/src/process.ts +++ b/packages/subagent/subagent-claude-code/src/process.ts @@ -6,6 +6,7 @@ */ import { EventEmitter } from 'node:events' +import { extname } from 'node:path' import type { SpawnedProcess, SpawnOptions, @@ -16,6 +17,8 @@ import { type SubprocessSpawnSpec, } from '@deepseek-ai/dsh-subprocess' +const WINDOWS_BATCH_EXECUTABLE_ENV = 'DSH_CLAUDE_CODE_EXECUTABLE' + function thrown(value: unknown): Error { /* v8 ignore next -- the subprocess seam rejects with Error. */ return value instanceof Error ? value : new Error(String(value)) @@ -40,22 +43,33 @@ export function sdkEnvironmentOverlay( * Translate one official SDK spawn request to the shared process owner. * @param options - command, arguments, workspace, environment, and forwarded signal from the SDK. * @param graceMs - process-tree termination grace. + * @param platform - host platform selecting the Windows batch-shim boundary. * @returns the fully explicit shared subprocess request. + * @remarks The batch-shim path quotes only the resolved executable. The pinned SDK + * supplies fixed flag arguments without cmd metacharacters; cmd reparses that tail. */ export function claudeSpawnSpec( options: SpawnOptions, graceMs: number, + platform: NodeJS.Platform = process.platform, ): SubprocessSpawnSpec { if (options.cwd === undefined || options.cwd.length === 0) { throw new Error('subagent-claude-code: SDK spawn request omitted its workspace') } + const extension = extname(options.command).toLowerCase() + const batchShim = platform === 'win32' && (extension === '.cmd' || extension === '.bat') + const env = sdkEnvironmentOverlay(options.env) + const argv = batchShim + ? ['cmd.exe', '/d', '/v:off', '/s', '/c', `%${WINDOWS_BATCH_EXECUTABLE_ENV}%`, ...options.args] + : [options.command, ...options.args] + if (batchShim) env[WINDOWS_BATCH_EXECUTABLE_ENV] = `"${options.command}"` return { - argv: [options.command, ...options.args], + argv, cwd: options.cwd, stdio: { stdin: 'pipe', stdout: 'pipe', stderr: 'inherit' }, graceMs, signal: options.signal, - env: sdkEnvironmentOverlay(options.env), + env, } } diff --git a/packages/subagent/subagent-claude-code/src/run.ts b/packages/subagent/subagent-claude-code/src/run.ts index 9dc4d740ac..6c1e0a8dbf 100644 --- a/packages/subagent/subagent-claude-code/src/run.ts +++ b/packages/subagent/subagent-claude-code/src/run.ts @@ -44,6 +44,8 @@ export const DEFAULT_DISPOSE_GRACE_MS = 3_000 export interface ClaudeCodeRunSpec { /** Parent Session workspace supplied to the SDK and real CLI. */ readonly cwd: string + /** Exact native Claude Code executable resolved from the host PATH. */ + readonly executable: string /** Explicit deployment/test environment layered after shared scrubbing. */ readonly env: Record /** Subprocess termination grace passed to the shared process-tree owner. */ @@ -180,6 +182,7 @@ export function claudeQueryOptions( return { abortController: controller, cwd: spec.cwd, + pathToClaudeCodeExecutable: spec.executable, env: { ...scrubbedParentEnv(), ...spec.env }, persistSession: false, disallowedTools: ['AskUserQuestion'], diff --git a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts index 806181ad13..9c2c9c25e5 100644 --- a/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-claude-code/tests/real-deepseek.e2e.ts @@ -7,10 +7,10 @@ import { rmSync, } from 'node:fs' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -87,6 +87,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY)( ]) mkdirSync(directory) const env = { + PATH: `${dirname(claudeBin)}${delimiter}${process.env.PATH ?? ''}`, ANTHROPIC_AUTH_TOKEN: apiKey, ANTHROPIC_BASE_URL: `${deepSeekBaseUrl()}/anthropic`, ANTHROPIC_MODEL: 'deepseek-v4-pro[1m]', diff --git a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts index 00e56c23b8..9320cb0e7c 100644 --- a/packages/subagent/subagent-claude-code/tests/real-product.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/real-product.spec.ts @@ -3,11 +3,12 @@ import { mkdirSync, mkdtempSync, readFileSync, + symlinkSync, writeFileSync, } from 'node:fs' import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' -import { dirname, join, resolve } from 'node:path' +import { delimiter, dirname, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' import type { @@ -15,11 +16,11 @@ import type { SDKMessage, SDKSystemMessage, } from '@anthropic-ai/claude-agent-sdk' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' -import type { SubprocessHandle } from '@deepseek-ai/dsh-subprocess' +import type { SubprocessHandle, SubprocessSpawnSpec } from '@deepseek-ai/dsh-subprocess' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import * as claudeCode from '../src/index.ts' import { @@ -98,9 +99,11 @@ afterEach(async () => { interface RealHarness { readonly ctx: Context readonly handles: SubprocessHandle[] + readonly spawnSpecs: SubprocessSpawnSpec[] readonly parent: Agent readonly workspace: string readonly env: Record + readonly executable: string } async function realHarness(behavior: MessagesBehavior): Promise<{ @@ -112,9 +115,17 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const workspace = join(root, 'workspace') const claudeConfig = join(root, 'claude-config') const xdgConfig = join(root, 'xdg') + const nativeBin = join(root, 'native&%literal%!bang!bin') mkdirSync(workspace) mkdirSync(claudeConfig) mkdirSync(xdgConfig) + mkdirSync(nativeBin) + const executable = join(nativeBin, process.platform === 'win32' ? 'claude.cmd' : 'claude') + if (process.platform === 'win32') { + writeFileSync(executable, `@echo off\r\n"${claudeBin}" %*\r\n`) + } else { + symlinkSync(claudeBin, executable) + } writeFileSync( join(claudeConfig, 'settings.json'), `${JSON.stringify({ model: settingsModel }, null, 2)}\n`, @@ -122,6 +133,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ const fixture = await startMessagesFixture(behavior) fixtures.push(fixture) const env = { + PATH: `${nativeBin}${delimiter}${process.env.PATH ?? ''}`, ANTHROPIC_API_KEY: fakeKey, ANTHROPIC_BASE_URL: fixture.baseUrl, CLAUDE_CONFIG_DIR: claudeConfig, @@ -141,8 +153,10 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ await ctx.plugin(SubagentService) await ctx.plugin(LocalSubprocessService) const handles: SubprocessHandle[] = [] + const spawnSpecs: SubprocessSpawnSpec[] = [] const spawn = ctx.subprocess.spawn.bind(ctx.subprocess) vi.spyOn(ctx.subprocess, 'spawn').mockImplementation((spec) => { + spawnSpecs.push(spec) const handle = spawn(spec) handles.push(handle) return handle @@ -153,7 +167,7 @@ async function realHarness(behavior: MessagesBehavior): Promise<{ session: { header: { cwd: workspace } }, } as unknown as Agent return { - harness: { ctx, handles, parent, workspace, env }, + harness: { ctx, handles, spawnSpecs, parent, workspace, env, executable }, fixture, } } @@ -182,7 +196,7 @@ function startRequest( }) } -describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { +describe('real Claude Agent SDK 0.3.220 and its distributed Claude Code 2.1.220 fixture', { timeout: 60_000, }, () => { it('inherits host settings and sends the exact task and fake key to local Messages', async () => { @@ -195,7 +209,7 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { expect(sdkPackage.version).toBe('0.3.220') expect(sdkPackage.claudeCodeVersion).toBe('2.1.220') expect(sdkPackage.optionalDependencies[platformPackage]).toBe('0.3.220') - const version = await execFileAsync(claudeBin, ['--version'], { + const version = await execFileAsync(process.platform === 'win32' ? claudeBin : harness.executable, ['--version'], { env: { ...process.env, ...harness.env }, }) expect(version.stdout.trim()).toBe('2.1.220 (Claude Code)') @@ -212,6 +226,18 @@ describe('real Claude Agent SDK 0.3.220 and Claude Code 2.1.220', { message.type === 'system' && message.subtype === 'init', ) expect(initMessage?.claude_code_version).toBe('2.1.220') + if (process.platform === 'win32') { + expect(harness.spawnSpecs[0]?.argv.slice(0, 6)).toEqual([ + 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', + ]) + const batchExecutable = harness.spawnSpecs[0]?.env?.DSH_CLAUDE_CODE_EXECUTABLE + expect(batchExecutable?.startsWith('"')).toBe(true) + expect(batchExecutable?.endsWith('"')).toBe(true) + expect(batchExecutable?.slice(1, -1).toLowerCase()) + .toBe(harness.executable.toLowerCase()) + } else { + expect(harness.spawnSpecs[0]?.argv[0]).toBe(harness.executable) + } expect(fixture.requests).toHaveLength(1) const recorded = fixture.requests[0]! diff --git a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts index 8c4ac1708d..cee479fd9d 100644 --- a/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts +++ b/packages/subagent/subagent-claude-code/tests/subagent-claude-code.spec.ts @@ -6,8 +6,8 @@ import type { SDKResultMessage, SpawnOptions, } from '@anthropic-ai/claude-agent-sdk' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { afterEach, beforeEach, @@ -248,6 +248,7 @@ function fakeRun( const options: FakeRun['options'] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', + executable: '/native/claude', env: { ANTHROPIC_API_KEY: 'fake-key' }, disposeGraceMs: 5, spawn: (spawnSpec) => { @@ -331,6 +332,8 @@ describe('task admission and package contracts', () => { const child = fakeChild() const spawn = vi.spyOn(ctx.subprocess, 'spawn') .mockImplementation(() => child.handle) + const resolveExecutable = vi.spyOn(ctx.subprocess, 'resolveExecutable') + .mockResolvedValue('/native/claude') const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) await ctx.plugin(claudeCode, { env: { @@ -352,6 +355,11 @@ describe('task admission and package contracts', () => { ) expect(queryMock).not.toHaveBeenCalled() + resolveExecutable.mockRejectedValueOnce(new Error('claude missing from PATH')) + await expect(ctx.subagents.start('claude-code', request())) + .rejects.toThrow('claude missing from PATH') + expect(queryMock).not.toHaveBeenCalled() + const run = await ctx.subagents.start('claude-code', request()) child.settle({ exitCode: 9, signal: null }) child.stdout.end() @@ -362,6 +370,13 @@ describe('task admission and package contracts', () => { expect(warn).toHaveBeenCalledWith(expect.stringContaining( 'subagent-claude-code: child run failed (error):', )) + expect(resolveExecutable).toHaveBeenCalledWith( + 'claude', + expect.objectContaining({ ANTHROPIC_API_KEY: 'provider-fake-key' }), + expect.any(AbortSignal), + ) + expect(queryMock.mock.calls[0]?.[0].options.pathToClaudeCodeExecutable) + .toBe('/native/claude') expect(spawn).toHaveBeenCalledWith(expect.objectContaining({ cwd: process.cwd(), graceMs: 29, @@ -441,6 +456,22 @@ describe('official spawn projection', () => { )).toThrow('SDK spawn request omitted its workspace') }) + it.each(['cmd', 'bat'])('routes a Windows .%s shim through cmd.exe', (extension) => { + const command = String.raw`C:\Program Files\Claude\claude.${extension}` + const spec = claudeSpawnSpec(sdkSpawnOptions({ + command, + args: ['--output-format', 'stream-json'], + }), 7, 'win32') + + expect(spec.argv).toEqual([ + 'cmd.exe', '/d', '/v:off', '/s', '/c', '%DSH_CLAUDE_CODE_EXECUTABLE%', + '--output-format', 'stream-json', + ]) + expect(spec.env).toEqual(expect.objectContaining({ + DSH_CLAUDE_CODE_EXECUTABLE: `"${command}"`, + })) + }) + it('projects streams, exit facts, listeners, and idempotent tree termination', async () => { const child = fakeChild({ exitOnTerminate: false }) const process = new ManagedClaudeCodeProcess(child.handle) @@ -508,6 +539,7 @@ describe('query options and result mapping', () => { const captured: SubprocessHandle[] = [] const spec: ClaudeCodeRunSpec = { cwd: '/workspace', + executable: '/native/claude', env: { HOST_VISIBLE: 'overridden', ANTHROPIC_API_KEY: 'explicit-fake-key', @@ -523,6 +555,7 @@ describe('query options and result mapping', () => { expect(options).toMatchObject({ abortController: controller, cwd: '/workspace', + pathToClaudeCodeExecutable: '/native/claude', persistSession: false, disallowedTools: ['AskUserQuestion'], }) @@ -670,6 +703,7 @@ describe('run publication, cancellation, and settlement', () => { let index = 0 const spec: ClaudeCodeRunSpec = { cwd: '/workspace', + executable: '/native/claude', env: {}, disposeGraceMs: 5, spawn: () => children[index++]!.handle, @@ -720,6 +754,7 @@ describe('run publication, cancellation, and settlement', () => { request(undefined, parentAbort.signal), { cwd: '/workspace', + executable: '/native/claude', env: {}, disposeGraceMs: 5, spawn: () => child.handle, diff --git a/packages/subagent/subagent-codex/README.i18n.yaml b/packages/subagent/subagent-codex/README.i18n.yaml index 7da7a5fc78..1cfb9645c2 100644 --- a/packages/subagent/subagent-codex/README.i18n.yaml +++ b/packages/subagent/subagent-codex/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/subagent/subagent-codex/README.md -README.md: 686c1f4d47f9024bfe66a4b85490bf0f84610b61 -README.zh.md: afe5433a1d0453b25e346bd7a8a33006a055309c +README.md: 3d59ca1eaf3db9dd9d9d2cd451692ebd2a956ef4 +README.zh.md: b60cb1bba9b2d7b3f61c544c1600862a0ad6ce5b diff --git a/packages/subagent/subagent-codex/README.md b/packages/subagent/subagent-codex/README.md index 686c1f4d47..3d59ca1eaf 100644 --- a/packages/subagent/subagent-codex/README.md +++ b/packages/subagent/subagent-codex/README.md @@ -27,7 +27,7 @@ The provider advertises no optional start-time capabilities and reports `inherit Production resolves `codex` from `PATH` and uses the host's native Codex configuration and authentication. The plugin does not install Codex, select a model, create `CODEX_HOME`, log in, or probe a version. Credential-shaped ambient variables are removed by the subprocess seam, so an API key intended for the child must be supplied explicitly in `env`; ordinary ambient values such as `PATH` and `HOME` remain available unless overridden. -Install this package and add the following rows to your own `cordis.yml`. Shipped CLI configurations do not load this provider or expose `subagent_codex` by default. +Shipped profiles load this provider once on the host and start no Codex process until a tool call. Full Agent Presets carry the tool row below with `disabled: true`; copy a preset and remove that field to expose `subagent_codex` only to agents composed from the copy. A custom host composition can still use both rows directly. ```yaml - id: subagent-codex @@ -38,6 +38,7 @@ Install this package and add the following rows to your own `cordis.yml`. Shippe - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: codex toolName: subagent_codex diff --git a/packages/subagent/subagent-codex/README.zh.md b/packages/subagent/subagent-codex/README.zh.md index afe5433a1d..b60cb1bba9 100644 --- a/packages/subagent/subagent-codex/README.zh.md +++ b/packages/subagent/subagent-codex/README.zh.md @@ -27,7 +27,7 @@ 生产环境会从 `PATH` 中解析 `codex`,并使用宿主机原生的 Codex 配置与身份验证。本插件不安装 Codex、不选择模型、不创建 `CODEX_HOME`、不执行登录,也不探测版本。子进程 seam 会移除具有凭证特征的环境变量,因此供子进程使用的 API 密钥必须在 `env` 中显式提供;除非被覆盖,`PATH` 和 `HOME` 等普通环境变量值仍然可用。 -请安装此包,并将以下配置项添加到你自己的 `cordis.yml`。正式 CLI 配置默认不会加载此提供方,也不会暴露 `subagent_codex`。 +随附 profile 会在宿主上加载一次该提供方,而且在工具被调用前不会启动 Codex 进程。完整 Agent Preset 携带下列工具行并设置 `disabled: true`;复制一个 preset 后删除该字段,即可只向由该副本组装的 agent 暴露 `subagent_codex`。自定义宿主组装仍可直接使用两条配置行。 ```yaml - id: subagent-codex @@ -38,6 +38,7 @@ - id: tool-subagent-codex name: '@deepseek-ai/dsh-tool-subagent' + disabled: true config: provider: codex toolName: subagent_codex diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index 30788aa2e5..17e13287ef 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subagent/subagent-codex" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,20 +32,20 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sdk-protocol": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sdk-protocol": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -50,6 +57,6 @@ "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@openai/codex": "0.147.0", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-codex/src/index.ts b/packages/subagent/subagent-codex/src/index.ts index 23077e3b54..3b1bbec799 100644 --- a/packages/subagent/subagent-codex/src/index.ts +++ b/packages/subagent/subagent-codex/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-subagent-codex */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { MAX_TIMER_DELAY_MS } from '@deepseek-ai/dsh-timeout' import { assertPositiveFinite, diff --git a/packages/subagent/subagent-codex/src/invariant.ts b/packages/subagent/subagent-codex/src/invariant.ts index a0c094af9c..ec9a6302c4 100644 --- a/packages/subagent/subagent-codex/src/invariant.ts +++ b/packages/subagent/subagent-codex/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-codex' diff --git a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts index 5c39ebfb3d..c184a01bc9 100644 --- a/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts +++ b/packages/subagent/subagent-codex/tests/real-deepseek.e2e.ts @@ -11,7 +11,7 @@ import { tmpdir } from 'node:os' import { delimiter, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent-codex/tests/real-product.spec.ts b/packages/subagent/subagent-codex/tests/real-product.spec.ts index c39093b041..2aaed86bee 100644 --- a/packages/subagent/subagent-codex/tests/real-product.spec.ts +++ b/packages/subagent/subagent-codex/tests/real-product.spec.ts @@ -4,14 +4,14 @@ import { mkdirSync, mkdtempSync, readFileSync, - rmSync, writeFileSync, } from 'node:fs' +import { rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { delimiter, join, resolve } from 'node:path' import { fileURLToPath } from 'node:url' import { promisify } from 'node:util' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { afterEach, describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' @@ -41,7 +41,7 @@ afterEach(async () => { await Promise.all(contexts.splice(0).map(ctx => ctx.fiber.dispose())) await Promise.all(fixtures.splice(0).map(fixture => fixture.close())) for (const root of roots.splice(0)) { - rmSync(root, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 }) + await rm(root, { recursive: true, force: true, maxRetries: 10, retryDelay: 100 }) } }) diff --git a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts index 9318c59787..81923f2228 100644 --- a/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts +++ b/packages/subagent/subagent-codex/tests/subagent-codex.spec.ts @@ -1,6 +1,6 @@ import { PassThrough } from 'node:stream' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { describe, expect, it, vi } from 'vitest' import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index b070f9f5d6..0d7e60cc46 100644 --- a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml +++ b/packages/subagent/subagent-dsh-sdk/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/subagent/subagent-dsh-sdk/README.md -README.md: 0bbcfa105ecf024a2492d39d3bf8d28956110050 -README.zh.md: 8c8551f85951aa8475ab2ce95771e4d54e0ed89a +README.md: 493bb187d45c7654958cfb3dbbe1dee6bb21b368 +README.zh.md: 2e1d9b1e602f2180d20d43fe8c358163ec4ec024 diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index 0bbcfa105e..493bb187d4 100644 --- a/packages/subagent/subagent-dsh-sdk/README.md +++ b/packages/subagent/subagent-dsh-sdk/README.md @@ -10,7 +10,7 @@ The SDK provider runs each subagent as a complete DeepSeek Harness runtime in a The working directory resolves exactly like the ACP backend, through the seam's shared out-of-process helpers ([`dsh-subagent`](../subagent/README.md)): the configured `cwd` override when set (validated once at load), else the delegating parent session's cwd — never the server process's own cwd. The resolved path becomes the child process cwd and the workspace cwd of its SDK session. -The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete `assistant/message`, or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths. +The returned run id is minted in the parent namespace; the child runtime's session id exists only inside the child process. After publication the provider owns one SDK activity and reads the child's answer from its session events: the last complete non-empty `assistant/message` (an empty-content message that records usage is skipped), or the accumulated `text-delta` stream when no such message exists. Partial output remains available after cancellation or an error. `dispose()` is idempotent: it settles the result locally as `aborted` (there is no wire-level prompt cancel), then closes the runtime — a bounded protocol `shutdown` request followed by the shared stdin-EOF → SIGTERM → SIGKILL ladder to actual exit. diff --git a/packages/subagent/subagent-dsh-sdk/README.zh.md b/packages/subagent/subagent-dsh-sdk/README.zh.md index 8c8551f859..2e1d9b1e60 100644 --- a/packages/subagent/subagent-dsh-sdk/README.zh.md +++ b/packages/subagent/subagent-dsh-sdk/README.zh.md @@ -10,7 +10,7 @@ SDK 提供方会在全新的子进程中把每个 subagent 作为完整的 DeepS 工作目录的解析与 ACP 后端完全一致,并使用 seam 共享的进程外辅助工具([`dsh-subagent`](../subagent/README.md)):设置了 `cwd` 覆盖值时使用该值(加载时校验一次),否则使用发起委派的父会话 cwd,绝不使用服务器进程自身的 cwd。解析出的路径同时成为子进程 cwd 和其 SDK 会话的工作区 cwd。 -返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整的 `assistant/message`,或该活动中断前已经累积的 `text-delta` 流;部分答案在取消和错误路径上都得以保留。 +返回的 run id 在父级命名空间中生成;子运行时的会话 id 只存在于子进程内部。发布后,提供方拥有一段 SDK 活动,并从子会话事件中读取答案:最后一条完整且非空的 `assistant/message`(记录 usage 的空内容消息会被跳过);若没有这类消息,则取累积的 `text-delta` 流。取消或发生错误后,部分输出仍然可用。 `dispose()`(资源释放)是幂等的:先在本地把结果确定为 `aborted`(协议层面没有提示词取消机制),再关闭运行时,即先发出一次有界的协议 `shutdown` 请求,随后通过共享的 stdin-EOF → SIGTERM → SIGKILL 阶梯使进程实际退出。 diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 07217be7c1..5d15e3da45 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-subagent-dsh-sdk", "description": "Out-of-process SDK subagent backend: drives a child DeepSeek Harness runtime subprocess over stdio JSON-RPC through the TypeScript SDK client", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subagent/subagent-dsh-sdk" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,20 +32,20 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sdk-client": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sdk-client": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -48,6 +55,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-dsh-sdk/src/index.ts b/packages/subagent/subagent-dsh-sdk/src/index.ts index 09a1a64d32..c07cb86456 100644 --- a/packages/subagent/subagent-dsh-sdk/src/index.ts +++ b/packages/subagent/subagent-dsh-sdk/src/index.ts @@ -10,8 +10,8 @@ * @module @deepseek-ai/dsh-subagent-dsh-sdk */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { SubagentCapabilities, SubagentProvider, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { assertPositiveFinite, NO_START_CAPABILITIES, resolveChildCwd, validateConfiguredCwd } from '@deepseek-ai/dsh-subagent' import { diff --git a/packages/subagent/subagent-dsh-sdk/src/invariant.ts b/packages/subagent/subagent-dsh-sdk/src/invariant.ts index 2aca0413ba..cddaed01bf 100644 --- a/packages/subagent/subagent-dsh-sdk/src/invariant.ts +++ b/packages/subagent/subagent-dsh-sdk/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-dsh-sdk' diff --git a/packages/subagent/subagent-dsh-sdk/src/run.ts b/packages/subagent/subagent-dsh-sdk/src/run.ts index b8a01ea383..194ce3badf 100644 --- a/packages/subagent/subagent-dsh-sdk/src/run.ts +++ b/packages/subagent/subagent-dsh-sdk/src/run.ts @@ -16,7 +16,7 @@ import { DeepSeekHarness, type HarnessNotification } from '@deepseek-ai/dsh-sdk- import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import type { SubagentResult, SubagentRun, SubagentStartRequest, SubagentStopReason } from '@deepseek-ai/dsh-subagent' -import { settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent' +import { AssistantOutputFold, settleRunResult, subprocessRunHandle } from '@deepseek-ai/dsh-subagent' import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess' /** Resolved spawn spec for an SDK runtime child process (no defaults — see Config). */ @@ -163,24 +163,14 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe } const childSessionId = `session-${randomUUID().replaceAll('-', '')}` - // The child's final answer: the last complete assistant message when one - // exists, else the text streamed so far (a partial answer surviving cancel). - let lastMessage: ContentBlock[] | undefined - const partial: string[] = [] + // The child's final answer under the seam's canonical selection rule + // (`AssistantOutputFold`); a partial answer survives cancel and error paths. + const fold = new AssistantOutputFold() const observe = (notification: HarnessNotification): void => { if (notification.method !== 'session.event' || notification.params.sessionId !== childSessionId) return - const event = notification.params.event as SessionEvent - if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { - partial.push(event.data.chunk.text) - } else if (event.type === 'assistant/message') { - lastMessage = event.data.message.content - } - } - const collectOutput = (): ContentBlock[] => { - if (lastMessage !== undefined) return lastMessage - const text = partial.join('') - return text.length > 0 ? [{ type: 'text', text }] : [] + fold.push(notification.params.event as SessionEvent) } + const collectOutput = (): ContentBlock[] => fold.collect() ?? [] // Race the child turn against local cancellation; the shared settlement // flattens failures under the seam's never-reject contract. diff --git a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index e42a76c90c..1f518bde8d 100644 --- a/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts +++ b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts @@ -7,7 +7,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { existsSync, mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' @@ -176,6 +176,20 @@ describe('dsh-subagent-dsh-sdk provider', () => { await ctx.fiber.dispose() }) + it('keeps streamed text when the terminal message is an empty usage-only step', async () => { + // The child streams its answer, then emits an empty-content + // assistant/message (the harness loop appends one to host usage on a + // max-tokens step that assembled no text blocks). The empty message is + // not assistant output and must not erase the streamed answer. + const ctx = await setup({ FAKE_EMPTY_MESSAGE: '1', FAKE_REASON_KIND: 'max-tokens' }) + const run = await ctx.subagents.start('dsh-sdk', request()) + const result = await run.result + expect(result.stopReason).toBe('max-tokens') + expect(text(result.output)).toBe('hello from fake runtime') + await run.dispose() + await ctx.fiber.dispose() + }) + it('reports a settled-without-turn child as an error', async () => { const ctx = await setup({ FAKE_REASON_KIND: 'none', FAKE_STATUS: 'error' }) const run = await ctx.subagents.start('dsh-sdk', request()) diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 1a77983d56..85ef8c6d88 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-subagent-fork", "description": "In-process fork subagent backend: runs a child agent seeded with a prefix of the parent's log", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subagent/subagent-fork" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,18 +32,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -46,6 +53,6 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-fork/src/index.ts b/packages/subagent/subagent-fork/src/index.ts index 7257c8dc06..0a3ea21aa2 100644 --- a/packages/subagent/subagent-fork/src/index.ts +++ b/packages/subagent/subagent-fork/src/index.ts @@ -7,8 +7,8 @@ * @module @deepseek-ai/dsh-subagent-fork */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import type { diff --git a/packages/subagent/subagent-fork/src/invariant.ts b/packages/subagent/subagent-fork/src/invariant.ts index e3d65701b1..7cff3d76f7 100644 --- a/packages/subagent/subagent-fork/src/invariant.ts +++ b/packages/subagent/subagent-fork/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-fork' diff --git a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts index 490b07f77b..7bff34c35b 100644 --- a/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts +++ b/packages/subagent/subagent-fork/tests/multi-subagent.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts index 70002b0e88..dcd3d0230d 100644 --- a/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts +++ b/packages/subagent/subagent-fork/tests/subagent-fork.spec.ts @@ -1,7 +1,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index a6a82fb47f..2514a7d420 100644 --- a/packages/subagent/subagent-inprocess/README.i18n.yaml +++ b/packages/subagent/subagent-inprocess/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/subagent/subagent-inprocess/README.md -README.md: 67f0cf5dd1ecb18542af56953a0eaa40988aca0d -README.zh.md: 648a160be5f1c3dcbe66a867a273a3df610dbc0a +README.md: 69def8bf8f41e3685d017ac4b003b26a37f064ef +README.zh.md: bf5e7cb5cc8517ee7020695ef10e3b58d613541b diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 67f0cf5dd1..69def8bf8f 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,13 +14,13 @@ The driver follows this sequence: 2. Call `parent.ctx.agents.create` directly, passing the required request signal into the factory's creation transaction. 3. During that transaction's unpublished setup window, install the requested persona, tool restriction, and structured-output runtime. 4. Publish the child, retain the returned `AgentHandle`, and drive one task with `child.followup(prompt)` followed by `child.whenIdle()`. -5. Read the child's own last assistant message and final durable turn reason from the complete owned child run, excluding any fork seed. +5. Read the child's own output — its last non-empty assistant message (an empty-content message that records usage is skipped), or its accumulated assistant text when no such message exists — and the final durable turn reason from the complete owned child run, excluding any fork seed. The child gets the parent's working-directory/session lineage and inherits the parent provider, model, and output-token cap unless `request.agentOptions` overrides them. It gets a fresh flat registration scope: parent ownership does not import parent tool restrictions or establish an authority subset. This result boundary is valid because the provider owns an isolated child lifecycle from publication through quiescence. Steering submitted during that lifecycle belongs to the child run; the provider does not pretend the initial follow-up alone owns its output. -When the optional sandbox-policy or approval service is composed, the driver snapshots the parent's explicit session override before child creation and appends a source-tagged event during unpublished setup, after any fork history and before session publication. It never copies deployment defaults or one-shot grants; later child switches still win. See the [policy-inheritance decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). +The driver applies the seam's [delegated policy](../subagent/README.md#delegated-policy) through the shared child-agent helpers: it captures the parent's explicit sandbox override and the `'never'` approval pin before child creation and appends the source-tagged events during unpublished setup, after any fork history and before session publication. See the [delegation-policy decision](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md). ## Cancellation and ownership diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 648a160be5..bf5e7cb5cc 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,13 +14,13 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 从完整的自有子运行中读取子 agent 自身最后一条 assistant 消息和最终持久化的轮次原因,并排除任何 fork 初始内容。 +5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条非空 assistant 消息(记录 usage 的空内容消息会被跳过),若没有这类消息则取其累积的 assistant 文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 该结果边界成立,是因为提供方拥有从发布到完全停稳的隔离子 agent 生命周期。在该生命周期内提交的 steering(中途引导)属于子运行;提供方不会声称输出只归初始 follow-up 所有。 -当组合中挂载了可选的沙箱策略或审批服务时,驱动器会在创建子 agent 前对父级的显式会话覆盖项获取快照,并在未发布的设置阶段追加一条带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。它绝不复制部署默认值或一次性授权;子 agent 后续的切换仍然优先。参见[策略继承决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 +驱动器通过共享的子 agent 辅助函数应用该 seam 的[委派策略](../subagent/README.md#delegated-policy):它会在创建子 agent 前捕获父级的显式沙箱覆盖项与 `'never'` 审批钉定,并在未发布的设置阶段追加带来源标记的事件,使其位于所有 fork 历史之后、会话发布之前。参见[委派策略决策](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)。 ## 取消与所有权 diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index 36646e7f7a..457bd16d36 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-subagent-inprocess", "description": "Shared in-process subagent run driver: drives a child agent on ctx.agents (used by the spawn and fork backends)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subagent/subagent-inprocess" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,28 +32,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-user-approval": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "peerDependenciesMeta": { - "@deepseek-ai/dsh-sandbox-policy": { - "optional": true - }, - "@deepseek-ai/dsh-user-approval": { - "optional": true - } + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -61,6 +58,6 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index ee7779447a..b0ab7425a5 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -12,14 +12,17 @@ */ import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent, AgentHandle } from '@deepseek-ai/dsh-agent' import { findLastMessageTurnEnd, SessionId, type SessionEvent, type TurnEndReason } from '@deepseek-ai/dsh-session' import { createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { + appendDelegatedPolicyOverrides, applyChildComposition, assertSubagentMaxDepth, + captureDelegatedPolicyOverrides, childSessionMeta, + finalAssistantOutput, resolveChildAgentOptions, resolveChildDepth, } from '@deepseek-ai/dsh-subagent' @@ -30,11 +33,6 @@ import type { SubagentRun, SubagentStopReason, } from '@deepseek-ai/dsh-subagent' -// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve -// to the policy services when composed — the driver consumes both -// opportunistically (the documented `ctx.get` pattern), never as a hard dep. -import type {} from '@deepseek-ai/dsh-sandbox-policy' -import type {} from '@deepseek-ai/dsh-user-approval' import { attachStructuredRuntime, type StructuredAttachment, @@ -111,20 +109,11 @@ export async function startInProcessRun( // Capture before the first await: a later parent switch belongs to the // parent's future. - const inheritedMode = parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session) - const inheritedPolicy = parent.ctx.get('approval')?.overrideOf(parent.session) + const inherited = captureDelegatedPolicyOverrides(parent) let structured: StructuredAttachment | undefined const setup = (childCtx: Context): void => { - // Inherited overrides land on the child's own log, so its effective policy - // is reconstructable from that log alone. - const childSession = (childCtx.agent as Agent).session - if (inheritedMode !== undefined) { - childSession.append('sandbox/mode', { mode: inheritedMode, source: 'delegation' }) - } - if (inheritedPolicy !== undefined) { - childSession.append('approval/policy', { policy: inheritedPolicy, source: 'delegation' }) - } + appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, inherited) applyChildComposition(childCtx, parent, { persona: request.persona, toolFilter: request.toolFilter, @@ -218,9 +207,9 @@ function readResult( structured?: { captured?: { value: unknown } | undefined }, ): SubagentResult { const own = child.session.events.slice(boundary) - const lastMessage = own.findLast((event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message') const lastEnd = findLastMessageTurnEnd(own) - const output: ContentBlock[] = lastMessage?.data.message.content ?? [] + // The seam's canonical selection rule; a partial answer survives cancel and truncation. + const output: ContentBlock[] = finalAssistantOutput(own) ?? [] const recorded = toStopReason(lastEnd?.data.reason) // Disposal can tear the owner down before the loop records its ordinary // `aborted` end, yielding `disposed` instead. diff --git a/packages/subagent/subagent-inprocess/src/invariant.ts b/packages/subagent/subagent-inprocess/src/invariant.ts index 4a2188dcc8..e8639e1f9a 100644 --- a/packages/subagent/subagent-inprocess/src/invariant.ts +++ b/packages/subagent/subagent-inprocess/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-inprocess' diff --git a/packages/subagent/subagent-inprocess/src/structured.ts b/packages/subagent/subagent-inprocess/src/structured.ts index 6bf5efbd35..d1471b3dde 100644 --- a/packages/subagent/subagent-inprocess/src/structured.ts +++ b/packages/subagent/subagent-inprocess/src/structured.ts @@ -10,7 +10,7 @@ * @module @deepseek-ai/dsh-subagent-inprocess/structured */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ToolSchema } from '@deepseek-ai/dsh-llm' import type { ToolExecution, ToolRunContext } from '@deepseek-ai/dsh-tools' import { ToolArgsError, validateJsonSchemaValue, type ObjectJsonSchema } from '@deepseek-ai/dsh-tools' diff --git a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts index 804249ba77..aa7437be81 100644 --- a/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/inheritance.spec.ts @@ -1,10 +1,13 @@ -/** Policy inheritance through child session events appended before publication. */ +/** + * Delegation policy through child session events appended before publication: + * the parent's sandbox override plus the pinned `approval/policy: never`. + */ import { afterEach, beforeEach, describe, expect, it } from 'vitest' import { mkdtemp, readFile, realpath, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -13,7 +16,7 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import SandboxPolicyService, { setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import * as ToolFs from '@deepseek-ai/dsh-tool-fs' -import ApprovalService, { setApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import ApprovalService from '@deepseek-ai/dsh-user-approval' import { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' @@ -76,12 +79,13 @@ function toolResultTexts(agent: Agent): string[] { } describe('in-process policy inheritance', () => { - it('records parent overrides before publishing a spawn child', async () => { + it('records the parent sandbox override and the approval pin before publishing a spawn child', async () => { const script: Script = [] const { ctx, parent } = await setupWalled(script) const blocked = join(workspace, 'spawn-blocked.txt') setSandboxMode(parent.session, 'read-only') - setApprovalPolicy(parent.session, 'never') + // No parent approval override: the child pin must not depend on one. + expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() const parentLogLength = parent.session.events.length script.push( toolCallResponse('write', 'write', { file_path: blocked, content: 'escaped' }), @@ -120,7 +124,10 @@ describe('in-process policy inheritance', () => { .join('\n') expect(contextText).toContain('Current DSH file policy: read-only') expect(contextText).toContain('Approval prompts are disabled') + // The statement rides runtime context; the system prompt stays uniform. + expect(contextText).toContain('You are a delegated subagent') expect(request.data.header.system).not.toContain('Approval prompts are disabled') + expect(request.data.header.system).not.toContain('You are a delegated subagent') expect(parent.session.events).toHaveLength(parentLogLength) } finally { await run.dispose() @@ -179,7 +186,7 @@ describe('in-process policy inheritance', () => { } }) - it('does not freeze deployment defaults into an unswitched child', async () => { + it('leaves an unswitched sandbox on the deployment default while still pinning approval', async () => { const script: Script = [] const { parent } = await setupWalled(script) const allowed = join(workspace, 'default-allowed.txt') @@ -193,12 +200,56 @@ describe('in-process policy inheritance', () => { await run.result const child = run.localAgent as Agent expect(await readFile(allowed, 'utf8')).toBe('fine') - expect(child.session.events.some( - event => event.type === 'sandbox/mode' || event.type === 'approval/policy', - )).toBe(false) + expect(child.session.events.some(event => event.type === 'sandbox/mode')).toBe(false) + expect(child.session.events.filter(event => event.type === 'approval/policy')).toMatchObject([ + { seq: 0, data: { policy: 'never', source: 'delegation' } }, + ]) expect(child.session.firstLiveSeq).toBe(0) } finally { await run.dispose() } }) + + it('rejects a child escalation deterministically even when an answerer would allow it', async () => { + const script: Script = [] + const { ctx, parent } = await setupWalled(script) + // A granting answerer proves the pin resolves before any answerer runs. + let consulted = false + ctx.on('approval/request', () => { + consulted = true + return Promise.resolve('allowed-once' as const) + }) + const blocked = join(workspace, 'escalation-blocked.txt') + setSandboxMode(parent.session, 'read-only') + script.push( + toolCallResponse('write', 'write', { + file_path: blocked, + content: 'escaped', + sandbox_permissions: 'workspace-write', + justification: 'test escalation from a delegated child', + }), + textResponse('child done'), + ) + + const run = await startInProcessRun(spawnRequest(parent), {}) + try { + await run.result + const child = run.localAgent as Agent + + await expect(readFile(blocked, 'utf8')).rejects.toMatchObject({ code: 'ENOENT' }) + expect(consulted).toBe(false) + expect(toolResultTexts(child).join('\n')) + .toContain('the user rejected escalating this operation to "workspace-write"') + const asked = child.session.events.find( + (event): event is SessionEvent<'approval/asked'> => event.type === 'approval/asked', + ) + const decided = child.session.events.find( + (event): event is SessionEvent<'approval/decided'> => event.type === 'approval/decided', + ) + expect(asked?.data.toolName).toBe('write') + expect(decided?.data).toMatchObject({ id: asked?.data.id, outcome: 'rejected' }) + } finally { + await run.dispose() + } + }) }) diff --git a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts index af199cd867..28306f0dcc 100644 --- a/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/preset-inheritance.spec.ts @@ -10,9 +10,9 @@ import { afterEach, describe, expect, it } from 'vitest' import { dirname, join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/subagent-inprocess/tests/structured.spec.ts b/packages/subagent/subagent-inprocess/tests/structured.spec.ts index 06fa641336..0c5b3db855 100644 --- a/packages/subagent/subagent-inprocess/tests/structured.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/structured.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage, CallId, type ContentBlock, type GenerateOptions } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -247,10 +247,10 @@ describe('in-process structured output', () => { const result = await run.result expect(result.stopReason).toBe('error') expect(result.structured).toBeUndefined() - // Exactly one model request and one user message: no nudge turn exists. + // Exactly one model request and one caller-supplied user message: no nudge turn exists. expect(adapter.requests.length).toBe(1) const child = ctx.agents.get(run.id)! - expect(child.session.events.filter(e => e.type === 'user/message').length).toBe(1) + expect(child.session.events.filter(e => e.type === 'user/message' && e.data.source.kind !== 'plugin').length).toBe(1) await run.dispose() }) diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index 46b94f9f5a..4b9041ebf9 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,6 +1,6 @@ -import { createUserMessage } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' @@ -10,7 +10,8 @@ import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' import * as AgentInvariant from '@deepseek-ai/dsh-agent/invariant' import * as AgentLoopInvariant from '@deepseek-ai/dsh-agent-loop/invariant' import SubagentService, { snapshotSubagentDescriptor } from '@deepseek-ai/dsh-subagent' -import { maxTokensResponse, MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { defineContentToolFixture } from '@deepseek-ai/dsh-tools' +import { maxTokensResponse, MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import { startInProcessRun } from '../src/index.ts' type Script = ConstructorParameters[0] @@ -155,6 +156,31 @@ describe('startInProcessRun', () => { await run.dispose() }) + it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => { + // A tool-only max-tokens step records an empty assistant/message for + // usage. The result retains the preceding assistant output. + const { ctx, parent } = await setup([ + toolCallResponse('t1', 'noop', {}, 'partial one'), + [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'max-tokens' } }, + ], + ]) + const disposeNoop = ctx.tools.register(defineContentToolFixture({ + name: 'noop', description: 'probe', parameters: {}, + execute() { return Promise.resolve([{ type: 'text', text: 'noop result' }]) }, + })) + const run = await startInProcessRun(request(parent), {}) + const result = await run.result + expect(result.stopReason).toBe('max-tokens') + expect(text(result.output)).toBe('partial one') + await run.dispose() + disposeNoop() + }) + it('seeds a forked child but reads only the child-owned output', async () => { const { ctx, parent } = await setup([textResponse('parent answer'), textResponse('child answer')]) parent.followup(createUserMessage({ content: [{ type: 'text', text: 'parent question' }], source: { kind: 'user' } })) @@ -278,7 +304,12 @@ describe('startInProcessRun', () => { const signalled = await startInProcessRun(request(parent, controller.signal), {}) await new Promise(resolve => setTimeout(resolve, 30)) controller.abort('stop child') - await expect(signalled.result).resolves.toMatchObject({ stopReason: 'aborted' }) + // No step completed a message, so the text streamed before the abort is + // the cancelled run's output. + await expect(signalled.result).resolves.toEqual({ + output: [{ type: 'text', text: 'partial' }], + stopReason: 'aborted', + }) expect(adapter.requests[0]?.signal?.reason).toEqual({ kind: 'parent' }) const child = parent.ctx.agents.get(signalled.id) const turnEnd = child?.session.events.findLast(event => event.type === 'turn/end') diff --git a/packages/subagent/subagent-inprocess/tsconfig.json b/packages/subagent/subagent-inprocess/tsconfig.json index 23406e362e..02fd8e53d0 100644 --- a/packages/subagent/subagent-inprocess/tsconfig.json +++ b/packages/subagent/subagent-inprocess/tsconfig.json @@ -32,14 +32,8 @@ { "path": "../../core/tools" }, - { - "path": "../../sandbox/sandbox-policy" - }, { "path": "../../support/invariants" - }, - { - "path": "../../interaction/user-approval" } ] } diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index 243e7afb52..094470807b 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-subagent-spawn", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subagent/subagent-spawn" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-subagent-inprocess": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -48,6 +55,6 @@ "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-spawn/src/index.ts b/packages/subagent/subagent-spawn/src/index.ts index d74114f55a..b7b6456270 100644 --- a/packages/subagent/subagent-spawn/src/index.ts +++ b/packages/subagent/subagent-spawn/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-subagent-spawn */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ContinuableCreateSpec, ResolvedSubagentStartRequest, diff --git a/packages/subagent/subagent-spawn/src/invariant.ts b/packages/subagent/subagent-spawn/src/invariant.ts index 0ba0182f9f..a6f1e1ccca 100644 --- a/packages/subagent/subagent-spawn/src/invariant.ts +++ b/packages/subagent/subagent-spawn/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subagent-spawn' diff --git a/packages/subagent/subagent-spawn/tests/harness.ts b/packages/subagent/subagent-spawn/tests/harness.ts index 33de6d0cc6..f8a45aef25 100644 --- a/packages/subagent/subagent-spawn/tests/harness.ts +++ b/packages/subagent/subagent-spawn/tests/harness.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts index 48220c1d9d..ba76c69203 100644 --- a/packages/subagent/subagent-spawn/tests/spawn.e2e.ts +++ b/packages/subagent/subagent-spawn/tests/spawn.e2e.ts @@ -3,7 +3,7 @@ import { mkdtemp, readFile, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, describe, expect, it } from 'vitest' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { spawnHarness, waitForIdle } from './harness.ts' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts index 508d5132fb..fdef892d7f 100644 --- a/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts +++ b/packages/subagent/subagent-spawn/tests/subagent-spawn.spec.ts @@ -1,7 +1,7 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context, symbols, type EffectMeta } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context, symbols, type EffectMeta } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import AgentRegistry, { type Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 495949dc0c..f3df2a0e62 100644 --- a/packages/subagent/subagent/README.i18n.yaml +++ b/packages/subagent/subagent/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/subagent/subagent/README.md -README.md: b69428e4af7d1f53adb22be1e59beb79c054713f -README.zh.md: 9f5eb5f1c508135c21bf3923f60f4f872de8e9f6 +README.md: b9f65befab71edc0685f1a6cca767c803afcb76c +README.zh.md: f58975815742ccbe790ca1cf096449831144a2f3 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index b69428e4af..b9f65befab 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -44,7 +44,7 @@ Every in-process child is composed by one call, `applyChildComposition(childCtx, `childSessionMeta()` records the joined preset id on the child's durable header for the same reason a top-level session records its own: the preset decides the tool schemas and prompt sections the model saw, so a cold read of the child's history has to rebuild that composition rather than the deployment default. It is read from the parent's live scope chain, not from the parent header, because a parent that switched preset while blank runs on the newer composition while its header still names the older one. -Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation. +Continuable creation is the optional `SubagentProvider.prepareContinuable?()` method: its presence is the capability check, so the service rejects a configured continuable start on a provider without it, while a provider that has it may still serve ordinary one-shot delegations. The method returns only a detached `ContinuableCreateSpec` (`{ seed? }`) — data, never a capability: it carries no Agent, `AgentHandle`, prompt delivery, result, disposal, or resume operation, because the continuation manager owns identity reservation, composition, Agent creation, prompt delivery, cold resume, ownership, and disposal after preparation. A one-shot `SubagentRun` represents one disposable foreground delegation with one result and no cold-resume operation. The service may invoke one provider concurrently for distinct siblings: each start or preparation owns its mutable state and cancellation path, and one operation's failure, result, or cleanup must not settle or release another. A provider may queue its own capacity internally without changing that independence contract. ## The durable descriptor @@ -56,11 +56,15 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th `inheritsParentContext` is descriptive rather than enforceable. It says only whether the child sees completed parent conversation history (`fork` does; `spawn` and the out-of-process one-shot providers do not), not whether it inherits tools, services, or authority. +## Delegated policy + +Both in-process delegation paths fix the child's permission scope at the delegation boundary through the shared child-agent helpers. `captureDelegatedPolicyOverrides(parent)` snapshots the parent session's explicit sandbox override (`sandboxPolicy.overrideOf()`) and pins the child's approval policy to `'never'` whenever the approval capability is composed — regardless of the parent's own policy — so a delegated child acts only within its inherited sandbox scope and every ask (for example a `sandbox_permissions` escalation) is rejected deterministically instead of waiting on a prompt no one is watching (both services are optional `ctx.get` consumers). `appendDelegatedPolicyOverrides()` writes each value onto the child's own log as a `source: 'delegation'` `sandbox/mode` or `approval/policy` event during unpublished setup, after any fork seed — so fresh policy wins stale seed state and the child's effective policy stays reconstructable from its log alone. The sandbox deployment default is never copied: an unswitched parent stamps no `sandbox/mode` and its child follows the deployment default dynamically. A continuable start captures before its first await and seeds only fresh materialization; a cold resume replays the persisted delegation events instead of re-capturing the parent, so a parent switch after creation never retroactively changes a durable child. Every in-process child also receives a scoped runtime-context statement (`subagent:delegation`) telling it the scope is fixed and that a task needing wider access ends with a reported limitation, not retries. See the [one-shot](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md) and [continuable](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md) delegation-policy Agent Notes. + ## One-shot ownership and lifecycle `provider.start(request): Promise` is the ownership-transfer boundary; the delegation tool also uses it inside its one-shot Task-backed background path. Before fulfillment, the provider owns setup and must cancel, roll back, and quiesce unpublished resources on every failure. After fulfillment, the caller owns the run and must call `dispose()` on every path; remaining prompt and turn work belongs to `SubagentRun.result`. -`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. +`SubagentRun.result` resolves to `{ output, structured?, stopReason }`. Child-level failures resolve with a non-`completed` reason; only an infrastructure fault that the seam cannot represent may reject. `dispose()` is idempotent, cancels remaining work, and waits for both result settlement and child-resource quiescence. A result rejection remains on `result`; `dispose()` rejects only for an independent resource-release failure. `output` and the `subagent/end` event's `lastAssistantMessage` use the exported `AssistantOutputFold`/`finalAssistantOutput` helpers to select the child's last non-empty assistant message, or its accumulated assistant text when no such message exists. `output` is `[]` and the event field is absent when the child produced neither ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the result contract). A local run publishes an ordinary child agent/session before `start()` fulfills, returns that shared session id as `SubagentRun.id`, exposes the exact child as `SubagentRun.localAgent`, records `request.parent.session.id` in the child's `parentSession` header, and appends the resolved descriptor inside its initial turn. Remote providers instead mint a parent-scoped lifecycle id and return `localAgent: undefined`; without a local child session, their one-shot runs are not part of trace-backed enumeration. @@ -96,11 +100,25 @@ Continuable Activations await a best-effort final session flush without treating ## Model Experience -Indirectly, through `dsh-tool-subagent`, `dsh-tool-subagent-control`, and `dsh-tool-subagent-report`. The first owns delegation schemas, the second owns parent continuation and discovery, and the third contributes `report` only to continuable child scopes. +### Child delegation-scope statement + +#### What the model sees + +Every in-process child's runtime-context snapshot carries the `subagent:delegation` statement below, after the sandbox-policy and approval-policy sentences; parent-side rendering stays with `dsh-tool-subagent` (delegation schemas), `dsh-tool-subagent-control` (continuation and discovery), and `dsh-tool-subagent-report` (the child-scoped `report`). + +##### The delegation-scope statement + +```markdown +You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it. +``` + +#### Token effect + +One fixed statement in each child's runtime-context snapshot; none in the parent's requests. #### KV Cache effect -No direct invalidation; the named consumers own any request-prefix changes. +Prefix-stable within a child: the statement never changes during the child's lifetime, so it is written once into the first runtime-context snapshot. Parent-side, no direct invalidation; the named tool consumers own any request-prefix changes. ## Known Limitations and Deferred Work diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 9f5eb5f1c5..f589758157 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -44,7 +44,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `childSessionMeta()` 把所加入的 preset id 记在子 agent 的持久化 header 上,理由与顶层会话记录自己的那一个相同:preset 决定了模型所见的工具 schema 与提示段,因此冷读子 agent 的历史时必须重建那份组装,而不是部署默认值。该值从父方**活着的** scope 链读取,而不是从父方 header 读取,因为在空白期切换过 preset 的父方运行在更新的那份组装上,而它的 header 仍写着旧的那个。 -可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。 +可继续创建对应可选的 `SubagentProvider.prepareContinuable?()` 方法:方法是否存在就是能力检查,因此服务会在没有该方法的提供方上拒绝已配置的可继续启动,而具备该方法的提供方仍可服务普通一次性委派。该方法只返回分离的 `ContinuableCreateSpec`(`{ seed? }`)——这是数据,绝非能力:它不携带任何 Agent、`AgentHandle`、提示词投递、结果、dispose 或恢复操作,因为准备之后,继续执行管理器拥有身份预留、组合、Agent 创建、提示词投递、冷恢复、所有权和 dispose。一次性 `SubagentRun` 表示一次可 dispose 的前台委派,只有一个结果,且没有冷恢复操作。服务可以针对不同的同级子 agent 并发调用同一提供方:每次启动或准备都拥有各自的可变状态和取消路径,一项操作的失败、结果或清理不得使另一项操作结算或释放。提供方可以在内部按自身容量排队,但不得改变这项独立性约定。 ## 持久化描述符 @@ -56,11 +56,15 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `inheritsParentContext` 只用于描述,不能强制执行。它仅说明子 agent 是否能看到父级已完成的对话历史(`fork` 可以;`spawn` 和各进程外一次性提供方不可以),不表示是否继承工具、服务或权限。 +## 委派策略 + +两条进程内委派路径都会通过共享的子 agent 辅助函数,在委派边界固定子 agent 的权限范围。`captureDelegatedPolicyOverrides(parent)` 对父会话的显式沙箱覆盖项(`sandboxPolicy.overrideOf()`)获取快照,并在审批能力已组合时把子 agent 的审批策略钉定为 `'never'`——无论父级自身的策略是什么——因此被委派的子 agent 只在其继承的沙箱范围内行动,每次请求(例如一次 `sandbox_permissions` 升级)都被确定性拒绝,而不是等待一个无人在看的提示(这两个服务都是可选的 `ctx.get` 消费方)。`appendDelegatedPolicyOverrides()` 则在未发布的设置阶段、在任何 fork 种子之后,把每个值作为一条 `source: 'delegation'` 的 `sandbox/mode` 或 `approval/policy` 事件写入子 agent 自己的日志:因此新鲜策略压过陈旧的种子状态,而子 agent 的生效策略始终可以仅凭其日志重建。沙箱的部署默认值绝不复制:未切换的父级不会记录 `sandbox/mode`,其子 agent 会动态跟随部署默认值。可继续启动会在其第一次 await 之前捕获,并且只为新鲜的物化写入种子;冷恢复会重放已持久化的委派事件,而不是重新捕获父级,因此创建之后的父级切换绝不会追溯性地改变持久化子 agent。每个进程内子 agent 还会收到一条作用域内的运行时上下文声明(`subagent:delegation`),告知其权限范围已固定,需要更宽访问的任务应以上报限制收尾,而不是重试。参见[一次性](../../../.agents/notes/implemented/feature/2026-07-25-subagent-policy-inheritance.md)与[可继续](../../../.agents/notes/implemented/feature/2026-08-10-continuable-subagent-policy-inheritance.md)两篇委派策略 Agent Note。 + ## 一次性所有权与生命周期 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。 +`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output` 与 `subagent/end` 事件的 `lastAssistantMessage` 使用导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数选取子 agent 最后一条非空 assistant 消息;若没有这类消息,则选取其累积的 assistant 文本。子 agent 两种输出均未产生时,`output` 为 `[]`,该事件字段缺省(结果约定归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 本地运行会在 `start()` 兑现前发布普通的子 agent/会话,把该共享会话 id 作为 `SubagentRun.id` 返回,以 `SubagentRun.localAgent` 公开准确的子 agent,把 `request.parent.session.id` 记录到子 agent 的 `parentSession` header,并在其初始轮次内追加已解析的描述符。远程提供方则生成 parent 作用域的生命周期 id,并返回 `localAgent: undefined`;由于没有本地 child 会话,其一次性运行不会进入基于追踪的枚举结果。 @@ -96,11 +100,25 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 ## 模型体验 -通过 `dsh-tool-subagent`、`dsh-tool-subagent-control` 和 `dsh-tool-subagent-report` 间接产生影响。第一个工具负责委派 schema,第二个负责父级延续和发现,第三个只向可继续子级作用域贡献 `report`。 +### 子级委派范围声明 + +#### 模型看到的内容 + +每个进程内子 agent 的运行时上下文快照都携带下方的 `subagent:delegation` 声明,位于沙箱策略与审批策略语句之后;父级侧的渲染仍归 `dsh-tool-subagent`(委派 schema)、`dsh-tool-subagent-control`(延续与发现)和 `dsh-tool-subagent-report`(子级作用域的 `report`)所有。 + +##### 委派范围声明 + +```markdown +You are a delegated subagent: your permission scope was fixed when you were started and cannot be widened from inside this session — operations that require approval are rejected automatically. When the task needs access beyond that scope, do not retry the denied operation; state the limitation in your reply so the delegating agent can handle it. +``` + +#### Token 影响 + +每个子 agent 的运行时上下文快照中一条固定声明;父级请求中没有任何新增。 #### KV Cache 影响 -不会直接使缓存失效;具名消费方共同负责请求前缀的任何变化。 +子级内部前缀稳定:该声明在子 agent 生命周期内绝不变化,因此只写入第一份运行时上下文快照一次。父级侧不会直接使缓存失效;具名工具消费方共同负责请求前缀的任何变化。 ## 已知限制与暂缓事项 diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 35504c9dc4..8a1bbef19f 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subagent/subagent" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -33,24 +40,33 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-agent-presets": "^0.0.1", - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-session-projection": "^0.0.1", - "@deepseek-ai/dsh-session-projection-cache": "^0.0.1", - "@deepseek-ai/dsh-tasks": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-agent-presets": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-session-projection-cache": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "peerDependenciesMeta": { "@deepseek-ai/dsh-agent-presets": { "optional": true }, + "@deepseek-ai/dsh-sandbox": { + "optional": true + }, + "@deepseek-ai/dsh-sandbox-policy": { + "optional": true + }, "@deepseek-ai/dsh-session-persistence": { "optional": true }, @@ -62,6 +78,9 @@ }, "@deepseek-ai/dsh-tasks": { "optional": true + }, + "@deepseek-ai/dsh-user-approval": { + "optional": true } }, "devDependencies": { @@ -70,6 +89,8 @@ "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-sandbox": "workspace:^", + "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", @@ -79,6 +100,7 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-user-approval": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent/src/activation-setup-registry.ts b/packages/subagent/subagent/src/activation-setup-registry.ts index 5681e89863..9317d5a2ac 100644 --- a/packages/subagent/subagent/src/activation-setup-registry.ts +++ b/packages/subagent/subagent/src/activation-setup-registry.ts @@ -11,7 +11,7 @@ * @module @deepseek-ai/dsh-subagent/activation-setup-registry */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { AgentSetupCommit } from '@deepseek-ai/dsh-agent' import { errorChain } from '@deepseek-ai/dsh-llm' import { SubagentError } from './error.ts' diff --git a/packages/subagent/subagent/src/assistant-output.ts b/packages/subagent/subagent/src/assistant-output.ts new file mode 100644 index 0000000000..6701327cfd --- /dev/null +++ b/packages/subagent/subagent/src/assistant-output.ts @@ -0,0 +1,74 @@ +/** + * Canonical selection of a child's final assistant output. Backend run results + * and `subagent/end.lastAssistantMessage` apply the same rule: select the last + * non-empty assistant message. An empty-content message records usage only + * when the loop appends it after a max-tokens step with no executable blocks, + * so it does not replace earlier output. If no non-empty message exists, + * select the accumulated assistant text. Selection is independent of the + * run's stop reason. + * + * @module @deepseek-ai/dsh-subagent/assistant-output + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +/** + * Incremental fold of the selection rule, for backends that observe a child's + * output as it streams: session-event backends {@link push} each event, and + * transports without session events (ACP content chunks) {@link pushText} raw + * text into the same streamed fallback. + */ +export class AssistantOutputFold { + private message: ContentBlock[] | undefined + private partial: string[] = [] + + /** + * Fold one session event: a non-empty assistant message becomes the + * candidate final answer, and a `text-delta` chunk extends the streamed + * fallback; every other event contributes nothing. + * @param event - the next observed session event. + */ + push(event: SessionEvent): void { + if (event.type === 'assistant/message') { + const content = event.data.message.content + if (content.length > 0) this.message = content + } else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { + this.pushText(event.data.chunk.text) + } + } + + /** + * Extend the streamed fallback with text observed outside session events. + * @param text - the next streamed text piece (an empty piece is a no-op). + */ + pushText(text: string): void { + if (text.length > 0) this.partial.push(text) + } + + /** + * Select the final output folded so far. + * @returns the last non-empty assistant message, else the accumulated + * streamed text, or `undefined` when the child produced neither. + */ + collect(): ContentBlock[] | undefined { + if (this.message !== undefined) return this.message + const text = this.partial.join('') + return text.length > 0 ? [{ type: 'text', text }] : undefined + } +} + +/** + * Apply the selection rule to one complete child-owned event suffix. + * @param events - the child-owned events (after any seed or epoch boundary). + * @returns the selected output, or `undefined` when the child produced none. + */ +export function finalAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined { + // TODO: this folds the complete suffix once per run/epoch settlement. If a + // long continuable epoch ever profiles hot here, scan backward with early + // exit for the last non-empty message and fold text deltas only on the + // no-message fallback. + const fold = new AssistantOutputFold() + for (const event of events) fold.push(event) + return fold.collect() +} diff --git a/packages/subagent/subagent/src/child-agent.ts b/packages/subagent/subagent/src/child-agent.ts index c501a19a56..7582338858 100644 --- a/packages/subagent/subagent/src/child-agent.ts +++ b/packages/subagent/subagent/src/child-agent.ts @@ -1,17 +1,24 @@ /** * Shared in-process child composition: the delegation-depth budget, the - * durable session metadata, the resolved child `AgentOptions`, and the scoped - * setup a child agent needs. Both the one-shot provider driver and the - * continuation manager compose children this way, so depth accounting and - * lineage stamping have one home. + * durable session metadata, the resolved child `AgentOptions`, the delegated + * policy seed, and the scoped setup a child agent needs. Both the one-shot + * provider driver and the continuation manager compose children this way, so + * depth accounting, lineage stamping, and delegation policy have one home. * * @module @deepseek-ai/dsh-subagent/child-agent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent, AgentOptions, CreateAgentOptions } from '@deepseek-ai/dsh-agent' -import type { SessionId } from '@deepseek-ai/dsh-session' +import type { SandboxMode } from '@deepseek-ai/dsh-sandbox' +import type { Session, SessionId } from '@deepseek-ai/dsh-session' import type { ToolRestriction } from '@deepseek-ai/dsh-tools' +// Type-only: make `ctx.get('sandboxPolicy')` / `ctx.get('approval')` resolve +// to the policy services when composed — delegation consumes both +// opportunistically (the documented `ctx.get` pattern), never as a hard dep — +// and merge the `sandbox/mode` / `approval/policy` session-event payloads. +import type {} from '@deepseek-ai/dsh-sandbox-policy' +import type {} from '@deepseek-ai/dsh-user-approval' // Type-only: make `ctx.get('agentPresets')` resolve to the preset roster when // composed — a child inherits its parent's composition opportunistically (the // documented `ctx.get` pattern), never as a hard dep. A rosterless deployment @@ -121,21 +128,34 @@ export interface ChildComposition { } /** - * Compose one child inside its creation window: join its parent's preset, then - * apply the child's own shadowing persona section and tool restriction, both - * owned by the child's scope and therefore invisible to its parent and - * siblings. + * Model-facing delegation-scope statement for every in-process child. A + * runtime-context contribution rather than a system-prompt section, so the + * deployment's system prompt stays uniform across parents and children. + */ +export const SUBAGENT_DELEGATION_CONTEXT + = 'You are a delegated subagent: your permission scope was fixed when you were started and cannot be ' + + 'widened from inside this session — operations that require approval are rejected automatically. ' + + 'When the task needs access beyond that scope, do not retry the denied operation; state the ' + + 'limitation in your reply so the delegating agent can handle it.' + +/** + * Compose one child inside its creation window: join its parent's preset, + * register the fixed delegation-scope statement, then apply the child's own + * shadowing persona section and tool restriction, all owned by the child's + * scope and therefore invisible to its parent and siblings. Creation and cold + * resume both pass through here. * * The join comes first and the child's own registrations second, which is the * order the layering already implies — the nearest scope wins a name, and a * per-child restriction intersects with everything its chain admits — but * stating it here keeps the two steps from being read as independent. * - * Both steps live in ONE call because a child composed with only the second is - * exactly the defect this function exists to prevent: with every model-facing - * row on the agent plane, a child that joins no preset sees an empty tool - * registry and none of its parent's prompt sections. Taking the parent as a - * parameter is what makes that omission unrepresentable at the call sites. + * The join and the per-child registrations live in ONE call because a child + * composed without the join is exactly the defect this function exists to + * prevent: with every model-facing row on the agent plane, a child that joins + * no preset sees an empty tool registry and none of its parent's prompt + * sections. Taking the parent as a parameter is what makes that omission + * unrepresentable at the call sites. * @param childCtx - the child agent's scoped creation context. * @param parent - the delegating parent whose composition the child joins. * @param composition - the per-child persona and tool filter to install. @@ -146,12 +166,64 @@ export function applyChildComposition( composition: ChildComposition, ): void { childCtx.get('agentPresets')?.composeFrom(childCtx, parent.ctx) + // Order 120: after the sandbox:policy (110) and approval:policy (115) sentences. + childCtx.systemPrompt.context({ name: 'subagent:delegation', order: 120, text: SUBAGENT_DELEGATION_CONTEXT }) if (composition.persona !== undefined) { childCtx.systemPrompt.section({ name: 'deployment:persona', order: 0, text: composition.persona }) } if (composition.toolFilter !== undefined) childCtx.tools.restrict(composition.toolFilter) } +/** Policy seeded onto a child session's log at the delegation boundary. */ +export interface DelegatedPolicyOverrides { + /** The parent session's explicit sandbox-mode override, or `undefined` without one. */ + readonly sandboxMode: SandboxMode | undefined + /** + * `'never'` whenever the approval capability is composed, `undefined` + * otherwise: a delegated child acts only within the sandbox scope fixed at + * delegation, so its asks are rejected deterministically. + */ + readonly approvalPolicy: 'never' | undefined +} + +/** + * Capture the policy to seed into one delegation. Call synchronously before + * the child start's first await: a later parent switch belongs to the + * parent's future, not to this child. Only the parent session's explicit + * sandbox override is captured — never deployment defaults or one-shot + * grants — and the approval policy is pinned to `'never'` regardless of the + * parent's own policy. + * @param parent - the delegating parent agent. + * @returns the sandbox override (or `undefined` without one) and the approval pin. + */ +export function captureDelegatedPolicyOverrides(parent: Agent): DelegatedPolicyOverrides { + return { + sandboxMode: parent.ctx.get('sandboxPolicy')?.overrideOf(parent.session), + approvalPolicy: parent.ctx.get('approval') === undefined ? undefined : 'never', + } +} + +/** + * Append the captured delegation policy onto the child's own log as + * `source: 'delegation'` events inside the unpublished creation window, so the + * child's effective policy is reconstructable from its log alone. Appends land + * after any fork seed, so fresh policy wins stale seed state; later child + * switches still win over these events. + * @param childSession - the unpublished child's session. + * @param overrides - the policy captured at delegation. + */ +export function appendDelegatedPolicyOverrides( + childSession: Session, + overrides: DelegatedPolicyOverrides, +): void { + if (overrides.sandboxMode !== undefined) { + childSession.append('sandbox/mode', { mode: overrides.sandboxMode, source: 'delegation' }) + } + if (overrides.approvalPolicy !== undefined) { + childSession.append('approval/policy', { policy: overrides.approvalPolicy, source: 'delegation' }) + } +} + /** Identity and lineage inputs shared by every in-process child creation. */ export interface ChildCreateInputs { /** The child's reserved session id. */ diff --git a/packages/subagent/subagent/src/continuation.ts b/packages/subagent/subagent/src/continuation.ts index 1cb16daca6..871ec6efdd 100644 --- a/packages/subagent/subagent/src/continuation.ts +++ b/packages/subagent/subagent/src/continuation.ts @@ -15,7 +15,7 @@ */ import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent, AgentHandle, @@ -32,11 +32,14 @@ import type { ToolRestriction } from '@deepseek-ai/dsh-tools' import { foldSubagentDescriptor, snapshotSubagentDescriptor } from './descriptor.ts' import type { SubagentDescriptorData } from './descriptor.ts' import { + appendDelegatedPolicyOverrides, applyChildComposition, + captureDelegatedPolicyOverrides, childSessionMeta, resolveChildAgentOptions, resolveChildDepth, } from './child-agent.ts' +import type { DelegatedPolicyOverrides } from './child-agent.ts' import { assertSubagentMaxDepth } from './depth.ts' import { seedDescriptorTurn } from './descriptor-seed.ts' import type { ContinuableCreateRequest, ContinuableCreateSpec, SubagentStartRequest } from './types.ts' @@ -203,8 +206,17 @@ interface MaterializeInputs { childId: SessionId provider: string parent: Agent - /** Creation inputs; absent for a cold resume, which loads the persisted session. */ - create?: { seed: readonly SessionEvent[]; meta: NonNullable } + /** + * Creation inputs; absent for a cold resume, which loads the persisted + * session — including the delegation policy events a fresh creation seeded, + * so a resume never re-captures the parent's policy. + */ + create?: { + seed: readonly SessionEvent[] + meta: NonNullable + /** Policy captured at the delegation boundary: the parent's sandbox override plus the approval pin. */ + delegatedPolicies: DelegatedPolicyOverrides + } agentOptions: AgentOptions composition: { persona?: string | undefined; toolFilter?: ToolRestriction | undefined } signal: AbortSignal @@ -341,6 +353,9 @@ export class SubagentContinuationManager { ...request.persona !== undefined ? { persona: request.persona } : {}, ...request.toolFilter !== undefined ? { toolFilter: request.toolFilter } : {}, }) + // Capture before the first await: a later parent switch belongs to the + // parent's future, not to this child. + const delegatedPolicies = captureDelegatedPolicyOverrides(parent) const prepared = await this.host.prepareContinuable(spec.provider, { sessionId: childId, @@ -357,7 +372,7 @@ export class SubagentContinuationManager { childId, provider: spec.provider, parent, - create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength) }, + create: { seed, meta: childSessionMeta(parent, childDepth, lineageSeedLength), delegatedPolicies }, agentOptions: resolveChildAgentOptions(parent, request.agentOptions, childDepth), composition: { persona: request.persona, toolFilter: request.toolFilter }, signal: spec.signal, @@ -878,18 +893,23 @@ export class SubagentContinuationManager { inputs: MaterializeInputs, parentLineage: readonly Agent[], ): Promise { - const { childId, provider, parent } = inputs + const { childId, provider, parent, create } = inputs // No id pre-check here: the child lock serializes each durable child, both // callers reach this only after confirming no Activation exists, and // `AgentRegistry.enter()` is the authoritative collision boundary for an id // some other owner holds — a duplicate would reject there with rollback. inputs.signal.throwIfAborted() const setup = (childCtx: Context): AgentSetupCommit => { + // Only fresh creation seeds the delegation policy onto the child's own + // log (after any fork seed, so fresh policy wins stale seed state); a + // cold resume replays those persisted events instead. + if (create !== undefined) { + appendDelegatedPolicyOverrides((childCtx.agent as Agent).session, create.delegatedPolicies) + } applyChildComposition(childCtx, parent, inputs.composition) return this.setupRegistry.apply(childCtx) } const observer = this.host.observeActivation(provider, childId, parent) - const { create } = inputs // Agent creation owns rollback before handle transfer. A rejection leaves // no resident Activation and therefore publishes no lifecycle edge. const handle: AgentHandle = create === undefined diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index eddcf63c3c..36acb8de28 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -31,7 +31,7 @@ * @module @deepseek-ai/dsh-subagent */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import type { Scoped } from '@deepseek-ai/dsh-scope' import { assertObjectJsonSchema } from '@deepseek-ai/dsh-tools' @@ -69,6 +69,7 @@ import { snapshotSubagentDescriptor } from './descriptor.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' export * from './out-of-process.ts' +export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts' export { SubagentRunId } from './types.ts' export type { ContinuableCreateRequest, @@ -100,13 +101,15 @@ export { SubagentError } from './error.ts' export { settleRun } from './run-settlement.ts' export { assertSubagentMaxDepth, delegationDepthOf } from './depth.ts' export { + appendDelegatedPolicyOverrides, applyChildComposition, + captureDelegatedPolicyOverrides, childSessionMeta, resolveChildAgentOptions, resolveChildDepth, SubagentDepthError, } from './child-agent.ts' -export type { ChildComposition } from './child-agent.ts' +export type { ChildComposition, DelegatedPolicyOverrides } from './child-agent.ts' export type { ContinuableStart, ContinuableStartSpec, @@ -122,7 +125,7 @@ export type { SubagentDescendantListEntry, SubagentListEntry } from './list-chil export type { SubagentRunEndInfo, SubagentRunInfo } from './types.ts' export type { SubagentIdentityProjection, SubagentTimingProjection } from './projection-types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { subagents: SubagentService } diff --git a/packages/subagent/subagent/src/invariant.ts b/packages/subagent/subagent/src/invariant.ts index d9a497eb77..c2ef451920 100644 --- a/packages/subagent/subagent/src/invariant.ts +++ b/packages/subagent/subagent/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned subagent registry and lifecycle invariants. @module @deepseek-ai/dsh-subagent/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { SubagentProvider, SubagentRunEndInfo, SubagentRunInfo } from './types.ts' diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 65c61ae9eb..3482a00c0b 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -15,11 +15,12 @@ */ import { randomUUID } from 'node:crypto' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { findLastMessageTurnEnd } from '@deepseek-ai/dsh-session' import type { SessionEvent, SessionId } from '@deepseek-ai/dsh-session' +import { finalAssistantOutput } from './assistant-output.ts' import { SubagentRunId } from './types.ts' import type { SubagentResult, SubagentRun, SubagentRunEndInfo, SubagentRunInfo } from './types.ts' @@ -128,7 +129,8 @@ export function observeRun( emit('subagent/end', { ...identity, stopReason: result.stopReason, - lastAssistantMessage: result.output, + // Omit the field when no output exists, matching continuable epochs. + ...result.output.length === 0 ? {} : { lastAssistantMessage: result.output }, }, parent) }, () => { @@ -173,7 +175,7 @@ export function createActivationObserver( }, capture: (child: Agent): void => { const own = child.session.events.slice(boundary) - const output = lastAssistantOutput(own) + const output = finalAssistantOutput(own) captured = { stopReason: epochStopReason(own), ...output === undefined ? {} : { output }, @@ -220,19 +222,6 @@ function epochStopReason(events: readonly SessionEvent[]): SubagentResult['stopR } } -/** - * The child's last assistant message content, for one Activation's terminal - * lifecycle edge. Absent when no assistant message reached the log. - * @param events - this epoch's own event suffix. - * @returns its final assistant content, or `undefined` when it produced none. - */ -function lastAssistantOutput(events: readonly SessionEvent[]): ContentBlock[] | undefined { - const message = events.findLast( - (event): event is SessionEvent<'assistant/message'> => event.type === 'assistant/message', - ) - return message?.data.message.content -} - /** Render any listener-thrown value without letting coercion escape containment. */ function renderThrown(value: unknown): string { try { diff --git a/packages/subagent/subagent/src/list-children.ts b/packages/subagent/subagent/src/list-children.ts index 8768a002b6..03f8da6c28 100644 --- a/packages/subagent/subagent/src/list-children.ts +++ b/packages/subagent/subagent/src/list-children.ts @@ -16,7 +16,7 @@ * @module @deepseek-ai/dsh-subagent */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent, SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type { SessionPersistence } from '@deepseek-ai/dsh-session-persistence' import type { SessionProjectionRegistry } from '@deepseek-ai/dsh-session-projection' diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index de2fd37115..dffea92fbd 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -64,7 +64,11 @@ export interface SubagentRunEndInfo { readonly local: boolean /** The terminal stop reason. */ readonly stopReason: SubagentResult['stopReason'] - /** The child's final assistant output, absent on infrastructure rejection. */ + /** + * The child's final assistant output, selected by the same rule as + * {@link SubagentResult.output}; absent on infrastructure rejection or when + * the child produced none. + */ readonly lastAssistantMessage?: ContentBlock[] } @@ -213,7 +217,12 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM * The terminal outcome of a subagent run, resolved by {@link SubagentRun.result}. */ export interface SubagentResult { - /** The child's final assistant output (the last assistant message's content). */ + /** + * The child's final assistant output is the content of its last non-empty + * assistant message. Empty-content messages, including usage-only messages, + * are skipped. Without a non-empty message, the output is its accumulated + * assistant text stream, or `[]` when the child produced neither. + */ readonly output: ContentBlock[] /** * The structured result after a requested `outputSchema` was successfully @@ -268,7 +277,10 @@ export interface SubagentRun { /** * One registered transport for running child agents. Providers are trusted * same-process implementations; callers treat descriptors and returned values - * as borrowed immutable data. + * as borrowed immutable data. The service may call one provider concurrently + * for distinct children. Providers isolate operation-local mutable state; a + * shared capacity controller may delay an operation but must not couple its + * settlement or cleanup to a sibling. */ export interface SubagentProvider { /** Unique registry name (e.g. `spawn`, `fork`, `acp`). */ @@ -289,7 +301,8 @@ export interface SubagentProvider { * initial turn. Before fulfillment, the provider owns setup and cleans any * unpublished partial resources before rejecting. Ownership transfers on * fulfillment; subsequent turn or infrastructure failure settles through - * the returned run. + * the returned run. Distinct starts may overlap; cancellation, failure, + * result settlement, and disposal remain independent for each run. */ start(request: ResolvedSubagentStartRequest): Promise /** @@ -304,6 +317,8 @@ export interface SubagentProvider { * continuation manager owns identity reservation, composition, Agent * creation, prompt delivery, cold resume, ownership, and disposal, so a * provider never sees the child's Agent, handle, turns, or teardown. + * Distinct preparations may overlap; each follows its own signal and returns + * data belonging only to `request.sessionId`. */ prepareContinuable?(request: ContinuableCreateRequest): Promise } diff --git a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts index 6353f486c5..6231ed3936 100644 --- a/packages/subagent/subagent/tests/activation-setup-registry.spec.ts +++ b/packages/subagent/subagent/tests/activation-setup-registry.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SubagentActivationSetupRegistry from '../src/activation-setup-registry.ts' /** A child-like scoped context with observable disposal. */ diff --git a/packages/subagent/subagent/tests/assistant-output.spec.ts b/packages/subagent/subagent/tests/assistant-output.spec.ts new file mode 100644 index 0000000000..3d510a94e8 --- /dev/null +++ b/packages/subagent/subagent/tests/assistant-output.spec.ts @@ -0,0 +1,92 @@ +import { describe, expect, it } from 'vitest' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { AssistantOutputFold, finalAssistantOutput } from '../src/assistant-output.ts' + +function message(content: ContentBlock[]): SessionEvent { + return { type: 'assistant/message', data: { message: { content } } } as SessionEvent +} + +function textDelta(text: string): SessionEvent { + return { type: 'assistant/chunk', data: { chunk: { type: 'text-delta', text } } } as SessionEvent +} + +function reasoningDelta(text: string): SessionEvent { + return { type: 'assistant/chunk', data: { chunk: { type: 'reasoning-delta', text } } } as SessionEvent +} + +function toolResult(text: string): SessionEvent { + return { + type: 'tool/result', + data: { + message: { + content: [{ + type: 'tool-result', + toolCallId: 'call-1', + content: [{ type: 'text', text }], + isError: false, + }], + }, + }, + } as SessionEvent +} + +describe('finalAssistantOutput', () => { + it('selects the last non-empty message past a later empty usage-only message', () => { + const events = [ + message([{ type: 'text', text: 'step one' }]), + message([{ type: 'text', text: 'step two' }]), + message([]), + ] + expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'step two' }]) + }) + + it('prefers a non-empty message over text streamed before and after it', () => { + const events = [ + textDelta('earlier partial'), + message([{ type: 'text', text: 'complete answer' }]), + textDelta('later partial'), + message([]), + ] + expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }]) + }) + + it('treats textless assistant content as a non-empty message', () => { + const content: ContentBlock[] = [{ type: 'reasoning', text: 'complete reasoning' }] + expect(finalAssistantOutput([ + textDelta('streamed text'), + message(content), + textDelta('later partial'), + ])).toEqual(content) + }) + + it('falls back to text deltas without including reasoning or tool-result content', () => { + const events = [ + reasoningDelta('thinking'), + textDelta('partial '), + toolResult('tool output'), + textDelta('answer'), + message([]), + ] + expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'partial answer' }]) + }) + + it('returns undefined when the child produced neither messages nor text', () => { + expect(finalAssistantOutput([])).toBeUndefined() + expect(finalAssistantOutput([reasoningDelta('thinking'), message([])])).toBeUndefined() + }) +}) + +describe('AssistantOutputFold', () => { + it('folds raw text pieces into the same streamed fallback (ACP chunk transport)', () => { + const fold = new AssistantOutputFold() + fold.pushText('partial ') + fold.pushText('') + fold.pushText('answer') + expect(fold.collect()).toEqual([{ type: 'text', text: 'partial answer' }]) + }) + + it('collects undefined until any output is folded', () => { + expect(new AssistantOutputFold().collect()).toBeUndefined() + }) +}) diff --git a/packages/subagent/subagent/tests/continuation-inheritance.spec.ts b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts new file mode 100644 index 0000000000..79835b17d1 --- /dev/null +++ b/packages/subagent/subagent/tests/continuation-inheritance.spec.ts @@ -0,0 +1,231 @@ +/** + * Continuable-child delegation policy: a fresh continuable start seeds the + * parent's explicit sandbox override and the pinned `approval/policy: never` + * onto the child's own log as `source: 'delegation'` events, and a cold + * resume replays that persisted snapshot instead of re-capturing the parent + * (the one-shot `subagent-inprocess/tests/inheritance.spec.ts` counterpart). + */ + +import { afterEach, describe, expect, it, vi } from 'vitest' +import { mkdtempSync, rmSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import { Context } from '@deepseek-ai/cordis' +import type { Agent } from '@deepseek-ai/dsh-agent' +import AgentLoop from '@deepseek-ai/dsh-agent-loop' +import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' +import { createUserMessage } from '@deepseek-ai/dsh-llm' +import SandboxPolicyService, { effectiveSandboxMode, setSandboxMode } from '@deepseek-ai/dsh-sandbox-policy' +import { SessionId } from '@deepseek-ai/dsh-session' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' +import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' +import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' +import ApprovalService, { effectiveApprovalPolicy } from '@deepseek-ai/dsh-user-approval' +import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import SubagentService from '../src/index.ts' + +type Script = ConstructorParameters[0] + +const roots: string[] = [] +const contexts: Context[] = [] +afterEach(async () => { + for (const ctx of contexts.splice(0).reverse()) await ctx.fiber.dispose() + for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true }) +}) + +/** Boot the continuable stack plus both policy services the manager consumes opportunistically. */ +async function setup(script: Script) { + const ctx = new Context() + contexts.push(ctx) + await mountAgentLoopTestDependencies(ctx) + const root = mkdtempSync(join(tmpdir(), 'dsh-continuation-inherit-')) + roots.push(root) + await ctx.plugin(JsonlSessionPersistence, { root }) + await ctx.plugin(SandboxPolicyService, { mode: 'workspace-write', workspaceRoot: root }) + await ctx.plugin(ApprovalService) + await ctx.plugin(AgentLoop, { agents: [] }) + await ctx.plugin(SubagentService) + await ctx.plugin(SubagentSpawn, { providerName: 'spawn' }) + await ctx.plugin(SubagentFork, { providerName: 'fork' }) + ctx.llm.registerAdapter(['mock'], new MockAdapter(script)) + const parent = ctx.agentLoop.create(SessionId('parent'), { provider: 'mock', model: 'mock' }) + return { ctx, parent } +} + +function startSpec(parent: Agent, provider = 'spawn') { + return { + provider, + label: 'child task', + request: { prompt: [{ type: 'text' as const, text: 'child task' }], parent }, + signal: new AbortController().signal, + } +} + +/** Wait until a child's Activation is gone, i.e. its handle finished disposal. */ +async function waitNoActivation(ctx: Context, childId: SessionId): Promise { + await vi.waitFor(() => { + expect(ctx.agents.get(childId)).toBeUndefined() + }, { timeout: 15_000 }) +} + +function policyEvents(events: readonly SessionEvent[]) { + return events.filter(event => event.type === 'sandbox/mode' || event.type === 'approval/policy') +} + +describe('continuable policy inheritance', () => { + it('seeds the parent sandbox override and pins approval to never', { timeout: 20_000 }, async () => { + const { ctx, parent } = await setup([textResponse('child done')]) + setSandboxMode(parent.session, 'danger-full-access') + // No parent approval override: the child pin must not depend on one. + expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() + let child: Agent | undefined + ctx.on('agent/created', ({ agent }) => { + if (agent !== parent) child = agent + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + // The delegation events are appended in the creation window, so they are + // already the child's effective policy at inbox acceptance. + if (child === undefined) throw new Error('expected the continuable child to be created') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access') + expect(ctx.approval.overrideOf(child.session)).toBe('never') + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(policyEvents(loaded.events)).toMatchObject([ + { type: 'sandbox/mode', data: { mode: 'danger-full-access', source: 'delegation' } }, + { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, + ]) + // Durable: a reload folds the same effective policy. + expect(effectiveSandboxMode(loaded.events)).toBe('danger-full-access') + expect(effectiveApprovalPolicy(loaded.events)).toBe('never') + expect(ctx.approval.overrideOf(parent.session)).toBeUndefined() + const runtimeContext = loaded.events.find( + (event): event is SessionEvent<'user/message'> => event.type === 'user/message' + && event.data.source.kind === 'plugin' + && event.data.source.plugin === '@deepseek-ai/dsh-system-prompt', + ) + const contextText = runtimeContext?.data.content + .flatMap(block => block.type === 'text' ? [block.text] : []) + .join('\n') + expect(contextText).toContain('You are a delegated subagent') + }) + + it('captures policy at delegation before asynchronous child creation', { timeout: 20_000 }, async () => { + const { ctx, parent } = await setup([textResponse('child done')]) + setSandboxMode(parent.session, 'read-only') + + const starting = ctx.subagents.startContinuable(startSpec(parent)) + // A parent switch after the synchronous capture belongs to the parent's + // future, not to this child. + setSandboxMode(parent.session, 'danger-full-access') + const started = await starting + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(ctx.sandboxPolicy.overrideOf(parent.session)).toBe('danger-full-access') + expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + }) + + it('leaves an unswitched sandbox on the deployment default while still pinning approval', { timeout: 20_000 }, async () => { + const { ctx, parent } = await setup([textResponse('child done')]) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(policyEvents(loaded.events)).toMatchObject([ + { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, + ]) + expect(effectiveSandboxMode(loaded.events)).toBeUndefined() + }) + + it('pins approval after the fork prefix of an unswitched fork child', { timeout: 20_000 }, async () => { + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')]) + parent.followup(createUserMessage({ + content: [{ type: 'text', text: 'parent work' }], + source: { kind: 'user' }, + })) + await parent.whenIdle() + + const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.meta.seedLength).toBeGreaterThan(0) + expect(policyEvents(loaded.events)).toMatchObject([ + { type: 'approval/policy', data: { policy: 'never', source: 'delegation' } }, + ]) + expect(effectiveSandboxMode(loaded.events)).toBeUndefined() + }) + + it('lets a later child-side switch win over the delegation snapshot', { timeout: 20_000 }, async () => { + const { ctx, parent } = await setup([textResponse('child done')]) + setSandboxMode(parent.session, 'danger-full-access') + let child: Agent | undefined + ctx.on('agent/created', ({ agent }) => { + if (agent !== parent) child = agent + }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + if (child === undefined) throw new Error('expected the continuable child to be created') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('danger-full-access') + // Last event wins: the child's own runtime switch beats the seeded snapshot. + setSandboxMode(child.session, 'read-only') + expect(ctx.sandboxPolicy.overrideOf(child.session)).toBe('read-only') + + await waitNoActivation(ctx, started.childId) + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + }) + + it('cold-resumes on the persisted snapshot without re-capturing the parent', { timeout: 20_000 }, async () => { + const { ctx, parent } = await setup([textResponse('first'), textResponse('after resume')]) + setSandboxMode(parent.session, 'read-only') + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + // The parent widens AFTER the child was created; the resumed child keeps + // the delegation-time snapshot from its own log. + setSandboxMode(parent.session, 'danger-full-access') + await ctx.subagents.followup(parent, started.childId, [{ type: 'text', text: 'continue please' }], { + source: { kind: 'user' }, + signal: new AbortController().signal, + }) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ + { data: { mode: 'read-only', source: 'delegation' } }, + ]) + expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + // The approval pin is seeded once at creation, never re-appended on resume. + expect(loaded.events.filter(event => event.type === 'approval/policy')).toMatchObject([ + { data: { policy: 'never', source: 'delegation' } }, + ]) + }) + + it('places inherited events after a fork prefix so fresh policy wins stale seed state', { timeout: 20_000 }, async () => { + const { ctx, parent } = await setup([textResponse('parent turn'), textResponse('forked child')]) + // The stale mode lands inside the completed turn the fork seed replays. + setSandboxMode(parent.session, 'workspace-write') + parent.followup(createUserMessage({ + content: [{ type: 'text', text: 'parent work' }], + source: { kind: 'user' }, + })) + await parent.whenIdle() + setSandboxMode(parent.session, 'read-only') + + const started = await ctx.subagents.startContinuable(startSpec(parent, 'fork')) + await waitNoActivation(ctx, started.childId) + + const loaded = await ctx.sessionPersistence.load(started.childId) + expect(loaded.meta.seedLength).toBeGreaterThan(0) + expect(loaded.events.filter(event => event.type === 'sandbox/mode')).toMatchObject([ + { data: { mode: 'workspace-write' } }, + { data: { mode: 'read-only', source: 'delegation' } }, + ]) + expect(effectiveSandboxMode(loaded.events)).toBe('read-only') + }) +}) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 9370676f76..50bd71b9cc 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -12,10 +12,10 @@ import JsonlSessionPersistence from '@deepseek-ai/dsh-session-persistence-jsonl' import * as SubagentSpawn from '@deepseek-ai/dsh-subagent-spawn' import * as SubagentFork from '@deepseek-ai/dsh-subagent-fork' import type { GenerateOptions, MessageId, StreamChunk } from '@deepseek-ai/dsh-llm' -import { createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' +import { CallId, createUserMessage, LlmAdapter } from '@deepseek-ai/dsh-llm' import { defineTool } from '@deepseek-ai/dsh-tools' import InvariantService from '@deepseek-ai/dsh-invariants' -import { MockAdapter, textResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' +import { MockAdapter, textResponse, toolCallResponse } from '../../../core/agent-loop/tests/mock-adapter.ts' import SubagentService, { SubagentError, SUBAGENT_DESCRIPTOR_VERSION, @@ -103,9 +103,9 @@ function hasUserText(events: readonly SessionEvent[], text: string): boolean { && event.data.content.some(block => block.type === 'text' && block.text === text)) } -/** Every user-role message text in log order, for FIFO assertions. */ +/** Caller-supplied user message texts in log order (runtime-context snapshots excluded). */ function userTexts(events: readonly SessionEvent[]): string[] { - return events.flatMap(event => event.type === 'user/message' + return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) } @@ -1200,6 +1200,44 @@ describe('continuable review regressions', () => { expect(ends[1]!.lastAssistantMessage).toEqual([{ type: 'text', text: 'second answer' }]) }) + it('keeps the epoch\'s earlier text past a final empty usage-only message', async () => { + // A tool-only max-tokens step records an empty assistant/message for + // usage. The terminal event retains the previous assistant content, + // including its tool call but not the intervening tool result. + const { ctx, parent } = await setup([ + toolCallResponse('t1', 'noop', {}, 'partial one'), + [ + { type: 'block-start', index: 0, blockType: 'tool-call' }, + { type: 'tool-call-delta', index: 0, id: CallId('t2'), name: 'noop', argumentsDelta: '{}' }, + { type: 'block-end', index: 0, block: { type: 'tool-call', id: CallId('t2'), name: 'noop', arguments: '{}' } }, + { type: 'usage', usage: { inputTokens: 20, outputTokens: 5 } }, + { type: 'finish', reason: { kind: 'max-tokens' } }, + ], + ]) + ctx.tools.register(defineTool({ + name: 'noop', + description: 'does nothing', + parameters: {}, + output: { + schema: { type: 'object', additionalProperties: false, properties: {} }, + render: () => [{ type: 'text', text: 'noop' }], + }, + execute: () => Promise.resolve({}), + })) + const ends: SubagentRunEndInfo[] = [] + ctx.on('subagent/end', (info) => { ends.push(info) }) + + const started = await ctx.subagents.startContinuable(startSpec(parent)) + await waitNoActivation(ctx, started.childId) + + await vi.waitFor(() => { expect(ends).toHaveLength(1) }) + expect(ends[0]!.stopReason).toBe('max-tokens') + expect(ends[0]!.lastAssistantMessage).toEqual([ + { type: 'text', text: 'partial one' }, + { type: 'tool-call', id: 't1', name: 'noop', arguments: '{}' }, + ]) + }) + it('reports a resumed epoch that opened no turn without the previous answer', async () => { const { ctx, parent } = await setup([textResponse('first answer')]) const started = await ctx.subagents.startContinuable(startSpec(parent)) diff --git a/packages/subagent/subagent/tests/invariant.spec.ts b/packages/subagent/subagent/tests/invariant.spec.ts index 21bdda0e06..615dd02aa5 100644 --- a/packages/subagent/subagent/tests/invariant.spec.ts +++ b/packages/subagent/subagent/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { scopeTarget } from '@deepseek-ai/dsh-scope' import { SessionId } from '@deepseek-ai/dsh-session' import SubagentService, { SubagentRunId } from '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/subagent/tests/list-children.spec.ts b/packages/subagent/subagent/tests/list-children.spec.ts index fa2db5fd04..7a7afb5e22 100644 --- a/packages/subagent/subagent/tests/list-children.spec.ts +++ b/packages/subagent/subagent/tests/list-children.spec.ts @@ -3,7 +3,7 @@ import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { z } from 'zod' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createUserMessage } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index a50696cf2a..42c778627a 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { type Agent } from '@deepseek-ai/dsh-agent' import { HarnessError } from '@deepseek-ai/dsh-llm' @@ -15,6 +15,7 @@ import SubagentService, { type SubagentProvider, type SubagentResult, type SubagentRun, + type SubagentRunEndInfo, type SubagentStartRequest, } from '@deepseek-ai/dsh-subagent' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' @@ -263,6 +264,17 @@ describe('SubagentService', () => { stopReason: 'completed', })) + // The lifecycle event omits lastAssistantMessage when output is empty, + // matching the continuable epoch event. + const silent = new StubProvider('silent', NO_CAPS, { output: [], stopReason: 'completed' }) + subagents.registerProvider(silent) + const silentRun = await subagents.start('silent', baseRequest()) + await silentRun.result + await Promise.resolve() + const silentEnd = ended.mock.calls.map(call => call[0] as SubagentRunEndInfo).find(info => info.provider === 'silent') + expect(silentEnd).toBeDefined() + expect('lastAssistantMessage' in silentEnd!).toBe(false) + const failure = Promise.withResolvers() subagents.registerProvider({ name: 'infra', diff --git a/packages/subagent/subagent/tests/timing-projection.spec.ts b/packages/subagent/subagent/tests/timing-projection.spec.ts index 0165f73714..a854e69df2 100644 --- a/packages/subagent/subagent/tests/timing-projection.spec.ts +++ b/packages/subagent/subagent/tests/timing-projection.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' import SessionProjectionRegistry from '@deepseek-ai/dsh-session-projection' diff --git a/packages/subagent/subagent/tsconfig.json b/packages/subagent/subagent/tsconfig.json index afc6138bd9..18e08740bf 100644 --- a/packages/subagent/subagent/tsconfig.json +++ b/packages/subagent/subagent/tsconfig.json @@ -26,9 +26,18 @@ { "path": "../../core/scope" }, + { + "path": "../../interaction/user-approval" + }, { "path": "../../preset/agent-presets" }, + { + "path": "../../sandbox/sandbox" + }, + { + "path": "../../sandbox/sandbox-policy" + }, { "path": "../../session/session-persistence" }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 2848a7d14d..1a4a1ad4ad 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-subagent-control", "description": "Globally named send_message, interrupt_agent, and list_agents tools over ctx.subagents continuations", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subagent/tool-subagent-control" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -30,12 +37,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -50,6 +57,6 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/tool-subagent-control/src/index.ts b/packages/subagent/tool-subagent-control/src/index.ts index 43e3f52b4e..67c93cda5c 100644 --- a/packages/subagent/tool-subagent-control/src/index.ts +++ b/packages/subagent/tool-subagent-control/src/index.ts @@ -9,7 +9,7 @@ * @module @deepseek-ai/dsh-tool-subagent-control */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/subagent/tool-subagent-control/src/invariant.ts b/packages/subagent/tool-subagent-control/src/invariant.ts index c993426a26..2538fa5065 100644 --- a/packages/subagent/tool-subagent-control/src/invariant.ts +++ b/packages/subagent/tool-subagent-control/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent-control' diff --git a/packages/subagent/tool-subagent-control/src/list-agents.ts b/packages/subagent/tool-subagent-control/src/list-agents.ts index d47232b3cc..37a4c3bdff 100644 --- a/packages/subagent/tool-subagent-control/src/list-agents.ts +++ b/packages/subagent/tool-subagent-control/src/list-agents.ts @@ -7,7 +7,7 @@ * @module @deepseek-ai/dsh-tool-subagent-control/list-agents */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts index 0361471031..1c12602dd2 100644 --- a/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/list-agents.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts index c5ea8fd1b8..4c378fcbc6 100644 --- a/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts +++ b/packages/subagent/tool-subagent-control/tests/tool-subagent-control.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -158,7 +158,7 @@ describe('dsh-tool-subagent-control', () => { await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - const prompts = loaded.events.flatMap(event => event.type === 'user/message' + const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) // A follow-up is its own later turn, never steering inside the first one. @@ -274,7 +274,7 @@ describe('dsh-tool-subagent-control interrupt_agent', () => { expect(waking.isError).toBe(false) await waitNoActivation(ctx, started.childId) const loaded = await ctx.sessionPersistence.load(started.childId) - const prompts = loaded.events.flatMap(event => event.type === 'user/message' + const prompts = loaded.events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) expect(prompts).toEqual(['long work', 'parked follow-up', 'wake up']) diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index 12e39c341c..f9dcd90588 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-subagent-report", "description": "Child-scoped report tool over ctx.subagents continuations", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subagent/tool-subagent-report" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,14 +32,14 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -47,6 +54,6 @@ "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/tool-subagent-report/src/index.ts b/packages/subagent/tool-subagent-report/src/index.ts index 962d8cf382..b33a5a29ab 100644 --- a/packages/subagent/tool-subagent-report/src/index.ts +++ b/packages/subagent/tool-subagent-report/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-tool-subagent-report */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { Agent } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SubagentReportDelivery } from '@deepseek-ai/dsh-subagent' diff --git a/packages/subagent/tool-subagent-report/src/invariant.ts b/packages/subagent/tool-subagent-report/src/invariant.ts index 50abca31c6..93777a70ec 100644 --- a/packages/subagent/tool-subagent-report/src/invariant.ts +++ b/packages/subagent/tool-subagent-report/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent-report' diff --git a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts index 23757c2b54..f33901c8c7 100644 --- a/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts +++ b/packages/subagent/tool-subagent-report/tests/tool-subagent-report.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' @@ -411,9 +411,9 @@ describe('dsh-tool-subagent-report', () => { }) }) -/** Prove report delivery uses ordinary logged user messages. */ +/** Prove report delivery uses ordinary logged user messages (runtime-context snapshots excluded). */ function userTexts(events: readonly SessionEvent[]): string[] { - return events.flatMap(event => event.type === 'user/message' + return events.flatMap(event => event.type === 'user/message' && event.data.source.kind !== 'plugin' ? event.data.content.flatMap(block => block.type === 'text' ? [block.text] : []) : []) } diff --git a/packages/subagent/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index e5f0c43f61..7e038e3fa7 100644 --- a/packages/subagent/tool-subagent/README.i18n.yaml +++ b/packages/subagent/tool-subagent/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/subagent/tool-subagent/README.md -README.md: 6ec313b3b97f0ffa7488025d4314b1c6231a6f6a -README.zh.md: 1fd88363b3ade9d57c194580f81295eed139ac50 +README.md: 578ea4786e1d996251360c4aed92f2e882553681 +README.zh.md: baf91664530a637df96f1e7a51e6f0adabc0a8ae diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 6ec313b3b9..578ea4786e 100644 --- a/packages/subagent/tool-subagent/README.md +++ b/packages/subagent/tool-subagent/README.md @@ -8,7 +8,7 @@ The model-facing delegation tool over one configured `ctx.subagents` provider. C Each plugin instance binds one `provider` to one `toolName`; the model receives no provider selector. Load another distinctly named instance to expose another transport. The tool registers only while its provider exists, avoiding sibling load-order and provider-reload dependencies. Its description follows `provider.inheritsParentContext`: fresh children require standalone prompts, while forked children already see completed parent turns. -A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results without partial output. If result collection and disposal both reject, the errored result preserves both diagnostics. +A foreground call passes the execution signal through startup and execution, awaits `run.result`, and always awaits `run.dispose()` before returning. Only `completed` returns the canonical `{ kind: 'foreground', runId, output: JsonValue[] }`, rendered as the same final text; abort, refusal, token limit, and other failures become errored tool results whose message appends the child's preserved partial text (the `SubagentResult.output` selection) after the stop-reason headline, so a truncated answer is never reported as success yet never silently lost. If result collection and disposal both reject, the errored result preserves both diagnostics. With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` registers a plain parent-owned Task and returns canonical `{ kind: 'background', taskId }`, rendered as `started background subagent task `, even when the provider supports continuable children; generic task tools own its later status, collection, cancellation, and notices. `continuable` requires a provider with the `prepareContinuable` capability, calls `ctx.subagents.startContinuable()`, and returns `{ kind: 'continuable', subagentId }`, rendered as `started subagent `. The continuable route resolves at inbox acceptance: the child owns its own turns from there, so this call neither waits for nor collects a result, and the child does not report back — its transcript by that id is the source of its output, and the optional global `send_message` tool sends it more work. Starting continuable work does not require `send_message` to be loaded. See the [background subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md), the [continuable subagents Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md), and the [merged-service Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md). @@ -29,7 +29,7 @@ With `run_in_background: true`, `backgroundMode` selects the route. `one-shot` r ## Concurrency -Foreground and background calls are exclusive. Children may share the parent's workspace or external resources, and a unary classifier cannot prove that sibling delegations have disjoint effects. See the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md). +Foreground and background calls are concurrency-safe: sibling delegations in one assistant message overlap under the loop's rolling pool (`maxParallelToolCalls`), and results still commit in model order. Children work in their own sessions and a run never mutates the parent session; the one-shot background form's one parent-owned write — registering a Task — is a synchronous, commutative insertion that tolerates concurrent dispatch, so overlapping background calls acquire their task ids in dispatch-race order. Coordinating sibling workspace effects belongs to the model, exactly as it already does for background and continuable children. See the [parallel subagent Agent Note](../../../.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md) and the [parallel tool-call Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md). ## Model Experience diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 1fd88363b3..baf9166453 100644 --- a/packages/subagent/tool-subagent/README.zh.md +++ b/packages/subagent/tool-subagent/README.zh.md @@ -8,7 +8,7 @@ 每个插件实例把一个 `provider` 绑定到一个 `toolName`;模型不会收到提供方选择器。如需公开另一种传输,请加载另一个名称不同的实例。工具只在其提供方存在时注册,从而避免对同级加载顺序和提供方重新加载的依赖。工具描述遵循 `provider.inheritsParentContext`:新建子 agent(智能体)需要独立提示词,而 fork 子 agent 已能看到父级已完成轮次。 -前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,不包含局部输出。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。 +前台调用会让执行信号贯穿启动和执行,等待 `run.result`,并且在返回前总会等待 `run.dispose()`。只有 `completed` 会返回规范值 `{ kind: 'foreground', runId, output: JsonValue[] }`,并渲染为相同的最终文本;中止、拒绝、token 上限和其他失败都会变成出错的工具结果,其消息在终止原因标题之后附带子代理保留下来的部分文本(即 `SubagentResult.output` 的选取结果)——被截断的回答不会被报告为成功,也绝不会被悄悄丢弃。如果结果收集与 dispose(资源释放)都 reject,出错的结果会保留两项诊断信息。 设置 `run_in_background: true` 后,`backgroundMode` 会选择路由。`one-shot` 会注册一个归父级所有的普通 Task,并返回规范值 `{ kind: 'background', taskId }`,渲染为 `started background subagent task `,即使提供方支持可继续子 agent 也不例外;通用 Task 工具负责其后续状态、收集、取消和通知。`continuable` 要求提供方具备 `prepareContinuable` 能力,调用 `ctx.subagents.startContinuable()`,并返回 `{ kind: 'continuable', subagentId }`,渲染为 `started subagent `。可继续路由在 inbox 接受时结算:子 agent 自此拥有自己的轮次,因此该调用既不等待也不收集结果,而且子 agent 不会回报——通过该 id 查看其 transcript(文本记录)即是其输出来源,可选的全局 `send_message` 工具则向其发送更多工作。启动可继续工作不要求加载 `send_message`。见 [后台 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-08-background-subagent-tasks.md)、[可继续的 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-07-28-continuable-subagent-conversations.md)和[服务合并 Agent Note](../../../.agents/notes/implemented/simplification/2026-07-26-merge-subagent-control-service.md)。 @@ -29,7 +29,7 @@ ## 并发 -前台调用和后台调用均互斥。子 agent 可能共享父级工作区或外部资源,一元分类器无法证明同级委派的效果彼此不相交。见 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 +前台调用和后台调用均并发安全:同一条 assistant 消息中的同级委派会在循环的滚动池(`maxParallelToolCalls`)下重叠执行,结果仍按模型顺序提交。子 agent 在各自的会话中工作,一次运行绝不变更父会话;一次性后台形态对父级拥有状态的唯一写入是注册一个 Task——这是一次同步、可交换、能容忍并发分发的插入,因此重叠的后台调用按分发竞态顺序获得各自的 task id。协调同级工作区效果由模型负责,正如模型已经对后台和可继续子 agent 所承担的那样。见 [并行 subagent Agent Note](../../../.agents/notes/implemented/feature/2026-08-09-parallel-subagent-delegations.md) 和 [并行工具调用 Agent Note](../../../.agents/notes/implemented/feature/2026-07-10-parallel-tool-call-execution.md)。 ## 模型体验 diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index f7d4c4e84e..b4b912e7db 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subagent/tool-subagent" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,19 +32,19 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-tasks": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", @@ -51,6 +58,6 @@ "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 67894c32cb..88ed0cdfc1 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -8,8 +8,8 @@ * @module @deepseek-ai/dsh-tool-subagent */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { AgentOptions } from '@deepseek-ai/dsh-agent' import type { ContentBlock } from '@deepseek-ai/dsh-llm' @@ -134,6 +134,21 @@ function stopReasonError(result: SubagentResult): string | undefined { } } +/** + * Append the child's preserved partial answer to a stop-reason error so a + * truncated or cancelled child's real text still reaches the parent model. + * @param error - the stop-reason headline. + * @param output - the child's selected output (`SubagentResult.output`). + * @returns the headline, extended with the partial text when any exists. + */ +function withPartialText(error: string, output: ContentBlock[]): string { + const text = output + .filter((block): block is Extract => block.type === 'text') + .map(block => block.text) + .join('') + return text.length === 0 ? error : `${error}\nPartial output before the run ended:\n${text}` +} + type ForegroundToolResult = { readonly kind: 'foreground' readonly runId: SubagentRun['id'] @@ -149,8 +164,9 @@ async function settleForegroundRun(run: SubagentRun): Promise { const error = stopReasonError(result) if (error !== undefined) { - // The registry converts this throw to isError; partial output is not success. - throw new Error(error) + // The registry converts this throw to isError; partial output is not + // success, but the preserved partial answer still reaches the parent. + throw new Error(withPartialText(error, result.output)) } return { kind: 'foreground', @@ -314,6 +330,9 @@ export function apply(ctx: Context, config: Config): void { : outputValueText(value.output), }], }, + // Children never mutate the parent session; the one parent-owned write + // (tasks.start) is a synchronous commutative insertion. + isConcurrencySafe: () => true, async execute(args, exec) { const parent = exec.agent if (!parent) { diff --git a/packages/subagent/tool-subagent/src/invariant.ts b/packages/subagent/tool-subagent/src/invariant.ts index bd30f4c563..5b8facc900 100644 --- a/packages/subagent/tool-subagent/src/invariant.ts +++ b/packages/subagent/tool-subagent/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-subagent' diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts index 7365348410..84a20e0e68 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { type Agent } from '@deepseek-ai/dsh-agent' import SubagentService, { type SubagentStartRequest } from '@deepseek-ai/dsh-subagent' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/subagent/tool-subagent/tests/scripted-provider.ts b/packages/subagent/tool-subagent/tests/scripted-provider.ts index 01c0769cf8..0be724a8bb 100644 --- a/packages/subagent/tool-subagent/tests/scripted-provider.ts +++ b/packages/subagent/tool-subagent/tests/scripted-provider.ts @@ -1,6 +1,6 @@ /** Package-local scripted child boundary for deterministic tool-subagent tests. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import type { @@ -33,6 +33,8 @@ export interface Config { inheritsParentContext?: boolean /** Structured value returned when the request asks for one. */ structured?: unknown + /** Observes each start; the child's result additionally waits for the returned promise. */ + onStart?: (request: SubagentStartRequest) => Promise | void } /** Scripted provider whose result aborts if its signal or disposer wins first. */ @@ -68,9 +70,10 @@ class ScriptedSubagentProvider implements SubagentProvider { ...wantsStructured ? { structured: this.config.structured ?? { reply } } : {}, stopReason: state.cancelled ? 'aborted' : stopReason, }) - const result = new Promise((resolve) => { + const gate = Promise.resolve(this.config.onStart?.(request)) + const result = gate.then(() => new Promise((resolve) => { setTimeout(() => { resolve(resultFor()) }, 0) - }).finally(() => { + })).finally(() => { request.signal.removeEventListener('abort', onAbort) }) diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 04b127a856..6cf8dee746 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -2,8 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtempSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import path from 'node:path' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' @@ -128,20 +128,42 @@ describe('dsh-tool-subagent', () => { expect(foreground.isError).toBe(false) }) - it('keeps foreground and background calls exclusive', async () => { + it('classifies foreground and background calls concurrency-safe (sibling delegations overlap)', async () => { const ctx = await setup({ provider: 'mock' }) expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('subagent-foreground'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK' }, - })).toEqual({ kind: 'exclusive' }) + })).toEqual({ kind: 'parallel' }) expect(ctx.tools.executionMode({ signal: testToolSignal, callId: CallId('subagent-background'), name: 'subagent', arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true }, - })).toEqual({ kind: 'exclusive' }) + })).toEqual({ kind: 'parallel' }) + }) + + it('overlaps sibling foreground delegations dispatched concurrently', async () => { + // Two children each block until both have started: hidden serialization + // in the tool body, registry pipeline, or provider start path would + // deadlock here instead of passing silently. + const started: string[] = [] + let releaseBoth!: () => void + const bothStarted = new Promise((resolve) => { releaseBoth = resolve }) + const ctx = await setup({ provider: 'mock', enableRunInBackground: false }, { + onStart: (request: SubagentStartRequest) => { + started.push(request.label ?? '(unlabeled)') + if (started.length === 2) releaseBoth() + return bothStarted + }, + }) + const results = await Promise.all([ + callSubagent(ctx, { description: 'first', prompt: 'p1' }), + callSubagent(ctx, { description: 'second', prompt: 'p2' }), + ]) + expect(started.sort()).toEqual(['first', 'second']) + for (const result of results) expect(result.isError).toBe(false) }) it.each([ @@ -154,6 +176,9 @@ describe('dsh-tool-subagent', () => { const result = await callSubagent(ctx, { description: 'd', prompt: 'p' }) expect(result.isError).toBe(true) expect(text(result)).toContain(fragment) + // The failure is not partial success, but the child's preserved partial + // answer still reaches the parent model inside the error result. + expect(text(result)).toContain('scripted subagent reply') }) it('registers under a configurable toolName so multiple providers can coexist', async () => { @@ -954,6 +979,16 @@ describe('dsh-tool-subagent continuable background mode', () => { return { ctx, parent } } + it('classifies continuable background calls concurrency-safe', async () => { + const { ctx } = await continuableSetup() + expect(ctx.tools.executionMode({ + signal: testToolSignal, + callId: CallId('subagent-continuable'), + name: 'subagent', + arguments: { description: 'do work', prompt: 'Reply OK', run_in_background: true }, + })).toEqual({ kind: 'parallel' }) + }) + it('starts a continuable child and returns only its durable id, creating no Task', async () => { const { ctx, parent } = await continuableSetup() const schema = ctx.tools.schemas().find(s => s.name === 'subagent')! @@ -983,6 +1018,69 @@ describe('dsh-tool-subagent continuable background mode', () => { expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true) }) + it('isolates a cancelled continuable preparation from a concurrent sibling', async () => { + const { ctx, parent } = await continuableSetup() + const bothPreparing = Promise.withResolvers() + const releasePreparations = Promise.withResolvers() + const cancelled = new AbortController() + let preparationCount = 0 + let cancelledChildId: ReturnType | undefined + let survivingChildId: ReturnType | undefined + ctx.subagents.registerProvider({ + name: 'gated', + capabilities: { outputSchema: true, depthLimit: true, toolFilter: true, persona: true }, + inheritsParentContext: false, + start: async () => { throw new Error('continuable policy must not start a one-shot child') }, + prepareContinuable: async (request) => { + preparationCount += 1 + if (request.signal === cancelled.signal) cancelledChildId = request.sessionId + else survivingChildId = request.sessionId + if (preparationCount === 2) bothPreparing.resolve(undefined) + await releasePreparations.promise + return {} + }, + }) + tool.apply(ctx, { + provider: 'gated', + toolName: 'subagent_gated', + backgroundMode: 'continuable', + maxDepth: 3, + }) + + const execute = (callId: string, description: string, signal: AbortSignal) => ctx.tools.execute({ + signal, + callId: CallId(callId), + name: 'subagent_gated', + arguments: { description, prompt: 'work', run_in_background: true }, + agent: parent, + }) + const cancelledResult = execute('continuable-cancelled', 'cancelled sibling', cancelled.signal) + const survivingResult = execute('continuable-surviving', 'surviving sibling', testToolSignal) + await bothPreparing.promise + cancelled.abort() + releasePreparations.resolve(undefined) + + const [failed, succeeded] = await Promise.all([cancelledResult, survivingResult]) + expect(preparationCount).toBe(2) + expect(failed.isError).toBe(true) + expect(succeeded.isError).toBe(false) + expect(cancelledChildId).toBeDefined() + expect(survivingChildId).toBeDefined() + expect(ctx.agents.get(cancelledChildId!)).toBeUndefined() + await expect(ctx.sessionPersistence.load(cancelledChildId!)).rejects.toThrow(/not found/) + + expect(succeeded.isError ? undefined : succeeded.value).toEqual({ + kind: 'continuable', + subagentId: survivingChildId, + }) + await vi.waitFor(() => { + expect(ctx.agents.get(survivingChildId!)).toBeUndefined() + }, { timeout: 5_000 }) + const loaded = await ctx.sessionPersistence.load(survivingChildId!) + expect(loaded.events.some(event => event.type === 'subagent/descriptor')).toBe(true) + expect(loaded.events.some(event => event.type === 'assistant/message')).toBe(true) + }) + }) describe('background preflight failure (no orphaned child, by construction)', () => { diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index 7a68a9b91b..12f47c0400 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subprocess/subprocess-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -29,10 +36,10 @@ }, "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-subprocess": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "node-pty": "^1.1.0" @@ -41,6 +48,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subprocess/subprocess-local/src/index.ts b/packages/subprocess/subprocess-local/src/index.ts index 858bf3fc75..5242986b3b 100644 --- a/packages/subprocess/subprocess-local/src/index.ts +++ b/packages/subprocess/subprocess-local/src/index.ts @@ -10,7 +10,7 @@ import { constants } from 'node:fs' import { access, stat } from 'node:fs/promises' import { delimiter, extname, isAbsolute, resolve } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import * as nodePty from 'node-pty' import type { IPtyForkOptions } from 'node-pty' import { SubprocessService } from '@deepseek-ai/dsh-subprocess' diff --git a/packages/subprocess/subprocess-local/src/invariant.ts b/packages/subprocess/subprocess-local/src/invariant.ts index b15b2dd511..335533d854 100644 --- a/packages/subprocess/subprocess-local/src/invariant.ts +++ b/packages/subprocess/subprocess-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess-local' diff --git a/packages/subprocess/subprocess-local/tests/local.spec.ts b/packages/subprocess/subprocess-local/tests/local.spec.ts index c4e3f91522..e3131543f4 100644 --- a/packages/subprocess/subprocess-local/tests/local.spec.ts +++ b/packages/subprocess/subprocess-local/tests/local.spec.ts @@ -1,7 +1,7 @@ import { PassThrough } from 'node:stream' import { describe, expect, it, vi } from 'vitest' import { basename, dirname, relative, resolve } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import type { SubprocessSpawnSpec, SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from '@deepseek-ai/dsh-subprocess' import { childEnv } from '../src/spawn.ts' diff --git a/packages/subprocess/subprocess-local/tests/spawn.spec.ts b/packages/subprocess/subprocess-local/tests/spawn.spec.ts index 36da8307e3..4cffde6432 100644 --- a/packages/subprocess/subprocess-local/tests/spawn.spec.ts +++ b/packages/subprocess/subprocess-local/tests/spawn.spec.ts @@ -669,7 +669,7 @@ describe('tree-survivor escalation (terminate and bounded waits reach helpers th }) it('service teardown awaits tree survivors, not just handle settlement', async () => { - const { Context } = await import('cordis') + const { Context } = await import('@deepseek-ai/cordis') const { default: LocalSubprocessService } = await import('@deepseek-ai/dsh-subprocess-local') const ctx = new Context() const fiber = await ctx.plugin(LocalSubprocessService) diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index b7aa6308ad..55558024f4 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-subprocess", "description": "Subprocess seam (ctx.subprocess) for the DeepSeek Harness — managed process groups, bounded spill-backed output, and escalated kills behind one abstract service", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/subprocess/subprocess" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subprocess/subprocess/src/index.ts b/packages/subprocess/subprocess/src/index.ts index 081ff436ec..bf30817b57 100644 --- a/packages/subprocess/subprocess/src/index.ts +++ b/packages/subprocess/subprocess/src/index.ts @@ -8,7 +8,7 @@ * @module @deepseek-ai/dsh-subprocess */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { DSH_ENV_PREFIX } from './types.ts' import type { SubprocessHandle, SubprocessSpawnSpec } from './types.ts' import type { SubprocessTerminalHandle, SubprocessTerminalSpawnSpec } from './types.ts' @@ -65,7 +65,7 @@ export function scrubbedParentEnv(): Record { return env } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { subprocess: SubprocessService } diff --git a/packages/subprocess/subprocess/src/invariant.ts b/packages/subprocess/subprocess/src/invariant.ts index 3a6ae526b0..c9e6ee904e 100644 --- a/packages/subprocess/subprocess/src/invariant.ts +++ b/packages/subprocess/subprocess/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned invariant companion for the subprocess seam. @module @deepseek-ai/dsh-subprocess/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-subprocess' diff --git a/packages/subprocess/subprocess/tests/service.spec.ts b/packages/subprocess/subprocess/tests/service.spec.ts index e4f770a9a3..e4024b668c 100644 --- a/packages/subprocess/subprocess/tests/service.spec.ts +++ b/packages/subprocess/subprocess/tests/service.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { PassThrough } from 'node:stream' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { scrubbedParentEnv, SubprocessService } from '@deepseek-ai/dsh-subprocess' import type { SubprocessHandle, diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index b504cbe50d..e06a890806 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-acp-snapshot", "description": "ACP test kit: shared subprocess launcher, snapshot scenario harness, expected-output normalizers, and suite factory", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/support/acp-snapshot" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -30,13 +37,13 @@ "vitest": "^4.1.8" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/support/acp-snapshot/src/invariant.ts b/packages/support/acp-snapshot/src/invariant.ts index e94876100e..e9aada2488 100644 --- a/packages/support/acp-snapshot/src/invariant.ts +++ b/packages/support/acp-snapshot/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-acp-snapshot' diff --git a/packages/support/agent-loop-testkit/README.i18n.yaml b/packages/support/agent-loop-testkit/README.i18n.yaml index 2236b6aa8e..80da12640e 100644 --- a/packages/support/agent-loop-testkit/README.i18n.yaml +++ b/packages/support/agent-loop-testkit/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/agent-loop-testkit/README.md -README.md: 18c46069d3cfd402c83b5ecab68458667738163b -README.zh.md: 2151591154899094fe33f9c25c9b9144e6d02a22 +README.md: 3b7225b1cdd1960e4ab9fda36f89d1ab1ad672e3 +README.zh.md: b4e38202f45c3ac8f541e42eaba63acea3666ab8 diff --git a/packages/support/agent-loop-testkit/README.md b/packages/support/agent-loop-testkit/README.md index 18c46069d3..3b7225b1cd 100644 --- a/packages/support/agent-loop-testkit/README.md +++ b/packages/support/agent-loop-testkit/README.md @@ -7,7 +7,7 @@ Shared prerequisite mounting for tests that exercise the concrete `AgentLoop`. ` The caller registers adapters and optional plugins, mounts `AgentLoop` with the configuration under test, and disposes its own Context. System-prompt and tool-registry configuration can be forwarded through `options`; the helper does not provide test defaults beyond those owned by the services. A plugin-load failure rejects the helper call, while services activated earlier in the sequence remain owned by the caller's Context. ```ts -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/support/agent-loop-testkit/README.zh.md b/packages/support/agent-loop-testkit/README.zh.md index 2151591154..b4e38202f4 100644 --- a/packages/support/agent-loop-testkit/README.zh.md +++ b/packages/support/agent-loop-testkit/README.zh.md @@ -7,7 +7,7 @@ 调用方注册适配器和可选插件,使用待测配置挂载 `AgentLoop`,并 dispose(资源释放)自己的 Context。系统提示词和工具注册表配置可通过 `options` 转发;该辅助函数不提供超出服务自有默认值的测试默认值。插件加载失败会使辅助函数调用被拒绝,而顺序中较早激活的服务仍归调用方的 Context 所有。 ```ts -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/support/agent-loop-testkit/package.json b/packages/support/agent-loop-testkit/package.json index d6419f38dd..43c49f3953 100644 --- a/packages/support/agent-loop-testkit/package.json +++ b/packages/support/agent-loop-testkit/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/support/agent-loop-testkit" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,13 +32,13 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -41,6 +48,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/support/agent-loop-testkit/src/index.ts b/packages/support/agent-loop-testkit/src/index.ts index c7b0cb7304..95a4052372 100644 --- a/packages/support/agent-loop-testkit/src/index.ts +++ b/packages/support/agent-loop-testkit/src/index.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-agent-loop-testkit */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore from '@deepseek-ai/dsh-session' diff --git a/packages/support/agent-loop-testkit/src/invariant.ts b/packages/support/agent-loop-testkit/src/invariant.ts index 33ee4474f9..c9e346921e 100644 --- a/packages/support/agent-loop-testkit/src/invariant.ts +++ b/packages/support/agent-loop-testkit/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts b/packages/support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts index aa125b561f..233900fc65 100644 --- a/packages/support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts +++ b/packages/support/agent-loop-testkit/tests/agent-loop-testkit.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { renderPrompt } from '@deepseek-ai/dsh-system-prompt' import { mountAgentLoopTestDependencies } from '../src/index.ts' diff --git a/packages/support/invariants/README.i18n.yaml b/packages/support/invariants/README.i18n.yaml index ea7fbba8f3..3d484e2ba9 100644 --- a/packages/support/invariants/README.i18n.yaml +++ b/packages/support/invariants/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/invariants/README.md -README.md: 9a93187032b6f8e4f5d89e17e742baba41196ff9 -README.zh.md: 7f3fa1e23337e55a73928c3952aa4c925a5fb4e9 +README.md: d3823d05b14afb5f043239cd7153ee40d66f3e4e +README.zh.md: b45e028e0f3865470763360846edfe4beed8a8cb diff --git a/packages/support/invariants/README.md b/packages/support/invariants/README.md index 9a93187032..d3823d05b1 100644 --- a/packages/support/invariants/README.md +++ b/packages/support/invariants/README.md @@ -52,7 +52,7 @@ The root entrypoint of each owner remains independent of diagnostics. Loading th ## Composition ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' diff --git a/packages/support/invariants/README.zh.md b/packages/support/invariants/README.zh.md index 7f3fa1e233..b45e028e0f 100644 --- a/packages/support/invariants/README.zh.md +++ b/packages/support/invariants/README.zh.md @@ -52,7 +52,7 @@ interface Config { ## 组合 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as SessionInvariant from '@deepseek-ai/dsh-session/invariant' diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index f41c6bfde6..1d01c18bd0 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/support/invariants" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,12 +32,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/support/invariants/src/index.ts b/packages/support/invariants/src/index.ts index 917e5327d4..7bf675243a 100644 --- a/packages/support/invariants/src/index.ts +++ b/packages/support/invariants/src/index.ts @@ -6,10 +6,10 @@ * @module @deepseek-ai/dsh-invariants */ -import { Context, Service } from 'cordis' -import type { Inject } from 'cordis' -import z from 'schemastery' -import type Schema from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import type { Inject } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type Schema from '@deepseek-ai/schemastery' /** Runtime invariant selection configured on the service plugin. */ export interface Config { @@ -65,7 +65,7 @@ export class InvariantError extends Error { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { invariants: InvariantService } diff --git a/packages/support/invariants/src/invariant.ts b/packages/support/invariants/src/invariant.ts index 7780e987f5..81ecc5b2af 100644 --- a/packages/support/invariants/src/invariant.ts +++ b/packages/support/invariants/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from './index.ts' const PACKAGE_NAME = '@deepseek-ai/dsh-invariants' diff --git a/packages/support/invariants/tests/service.spec.ts b/packages/support/invariants/tests/service.spec.ts index 9000fb8955..483f769916 100644 --- a/packages/support/invariants/tests/service.spec.ts +++ b/packages/support/invariants/tests/service.spec.ts @@ -1,11 +1,11 @@ import { describe, expect, it, vi } from 'vitest' -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import InvariantService, { InvariantError, type Config, } from '@deepseek-ai/dsh-invariants' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { invariantProbe: InvariantProbeService } diff --git a/packages/support/llm-mock-server/README.i18n.yaml b/packages/support/llm-mock-server/README.i18n.yaml index c78f65cc91..df99292c06 100644 --- a/packages/support/llm-mock-server/README.i18n.yaml +++ b/packages/support/llm-mock-server/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/llm-mock-server/README.md -README.md: a535c086bf688ad48b1a3bb19c7b81da21cbad92 -README.zh.md: d814d8815b38bb34bd0d871d552e6f3ec75e042a +README.md: 919bbc923110459e9ee4fc26f6db7e8b35ab1abb +README.zh.md: d60c6b2395c14c0cf690c2a94e902eb1cfeffa94 diff --git a/packages/support/llm-mock-server/README.md b/packages/support/llm-mock-server/README.md index a535c086bf..919bbc9231 100644 --- a/packages/support/llm-mock-server/README.md +++ b/packages/support/llm-mock-server/README.md @@ -23,7 +23,7 @@ Point the shipping DeepSeek adapter at the server; it appends `/chat/completions ```sh DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ DEEPSEEK_API_KEY=mock-key \ -pnpm run demo:headless "test provider recovery" +pnpm dsh --profile headless "test provider recovery" ``` The repository script writes JSONL to stdout: a `ready` record carries the `/v1` base URL and random seed, followed by request/result records that name both the scripted behavior and the concrete selected behavior. The private support package exposes no installable binary. diff --git a/packages/support/llm-mock-server/README.zh.md b/packages/support/llm-mock-server/README.zh.md index d814d8815b..d60c6b2395 100644 --- a/packages/support/llm-mock-server/README.zh.md +++ b/packages/support/llm-mock-server/README.zh.md @@ -23,7 +23,7 @@ pnpm run mock:llm -- \ ```sh DEEPSEEK_BASE_URL=http://127.0.0.1:8000/v1 \ DEEPSEEK_API_KEY=mock-key \ -pnpm run demo:headless "test provider recovery" +pnpm dsh --profile headless "test provider recovery" ``` 仓库脚本将 JSONL 写入 stdout:`ready` 记录携带以 `/v1` 结尾的基础 URL 和随机种子,后续请求/结果记录同时命名脚本行为和实际选中的具体行为。这个私有支持包不公开可安装的二进制命令。 diff --git a/packages/support/llm-mock-server/package.json b/packages/support/llm-mock-server/package.json index 20b1cf6d85..203b092987 100644 --- a/packages/support/llm-mock-server/package.json +++ b/packages/support/llm-mock-server/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/support/llm-mock-server" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/support/llm-mock-server/src/invariant.ts b/packages/support/llm-mock-server/src/invariant.ts index b8fbc2dd40..a77bb42835 100644 --- a/packages/support/llm-mock-server/src/invariant.ts +++ b/packages/support/llm-mock-server/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-mock-server' diff --git a/packages/support/llm-mock-server/tests/invariant.spec.ts b/packages/support/llm-mock-server/tests/invariant.spec.ts index f45320d989..3bd7eb6d7b 100644 --- a/packages/support/llm-mock-server/tests/invariant.spec.ts +++ b/packages/support/llm-mock-server/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as MockServerInvariant from '../src/invariant.ts' diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index 6d048c2325..688e259468 100644 --- a/packages/support/llm-replay/README.i18n.yaml +++ b/packages/support/llm-replay/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/support/llm-replay/README.md -README.md: ae7cb5414a28bcf33f9878f6f02220861df9a0d5 -README.zh.md: 2acc0ff8e8c011126452ba3458aaeb0800d9d8f3 +README.md: 6119407c06cf734166300f5f70a3fe65d026d08d +README.zh.md: 75d74b654a9a53e7ada5cc854e275eadcbe01284 diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index ae7cb5414a..6119407c06 100644 --- a/packages/support/llm-replay/README.md +++ b/packages/support/llm-replay/README.md @@ -29,7 +29,7 @@ Replay keys every call by its calling session id (`GenerateOptions.sessionId`, s | `file` | string | `$DSH_SNAPSHOT_FILE` | Path to the primary (parent) `session.jsonl` fixture. Required (config or env). | | `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | Optional `ReplayOverrideDoc` sidecar for the primary session: a bare `ReplayEntry[]` replaces its derived script, while `{ patches }` augments it by call index. | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES` (path-delimited) | Recorded subagent child-session logs for a nested scenario; empty for a single-session scenario. | -| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow`; configured routes dispatch through the replay adapter and never perform provider I/O. | +| `providers` | `ReplayProviderConfig[]` | — | Optional replay-only provider and model catalog. Each provider may set `retryPolicy`, and each model may publish `contextWindow` and an `inputModalities` array containing only `text` and `image`; invalid modalities fail during plugin loading. Configured routes dispatch through the replay adapter and never perform provider I/O. | | `paceMs` | number | — (burst) | Optional per-chunk delay in ms so downstream transports (e.g. the web SSE mux observed by a real browser) see genuinely incremental delivery. A realism knob only — tests must not depend on it for correctness. Non-negative integer; abort during a pace wait cancels the stream promptly. | ```yaml diff --git a/packages/support/llm-replay/README.zh.md b/packages/support/llm-replay/README.zh.md index 2acc0ff8e8..75d74b654a 100644 --- a/packages/support/llm-replay/README.zh.md +++ b/packages/support/llm-replay/README.zh.md @@ -29,7 +29,7 @@ fixture 就是持久化的会话日志(`/session.jsonl`)。其 `as | `file` | string | `$DSH_SNAPSHOT_FILE` | 主(父)`session.jsonl` fixture 的路径。必需(配置或 env)。 | | `overrideFile` | string | `$DSH_SNAPSHOT_OVERRIDE` | 主会话的可选 `ReplayOverrideDoc` 伴随文件:裸 `ReplayEntry[]` 替换其派生脚本,`{ patches }` 则按调用索引增补该脚本。 | | `childFiles` | string[] | `$DSH_SNAPSHOT_CHILD_FILES`(以路径分隔符分隔) | 嵌套场景中已记录的 subagent 子会话日志;单会话场景为空。 | -| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow`;已配置路由通过回放适配器分派,绝不执行提供方 I/O。 | +| `providers` | `ReplayProviderConfig[]` | 无 | 可选的仅回放提供方和模型目录。每个提供方可以设置 `retryPolicy`,每个模型可以发布 `contextWindow` 和仅包含 `text`、`image` 的 `inputModalities` 数组;模态配置无效时,插件加载会失败。已配置路由通过回放适配器分派,绝不执行提供方 I/O。 | | `paceMs` | number | 无(突发) | 可选的每分片毫秒延迟,使下游传输(例如真实浏览器观察到的 Web SSE(Server-Sent Events)多路复用器)看到真正的增量传递。它只是仿真开关,测试不得依赖它保证正确性。值必须是非负整数;pace 等待期间中止会迅速取消流。 | ```yaml diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index af5e2929f3..3c05e7fbf6 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-llm-replay", "description": "Replay LLM plugin: short-circuits llm/stream with model chunks reconstructed from a recorded session JSONL (keyless snapshot tests)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/support/llm-replay" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-compact": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-compact": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-compact": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 868751d969..f4a8a82e81 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -9,7 +9,7 @@ import { existsSync, readFileSync, writeFileSync } from 'node:fs' import { delimiter as pathDelimiter } from 'node:path' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type {} from '@deepseek-ai/dsh-compact' import { decodeStorageRecord } from '@deepseek-ai/dsh-session' import type { SessionEvent } from '@deepseek-ai/dsh-session' @@ -19,6 +19,7 @@ import type { LlmModelInfo, LlmProviderInfo, LlmResolvedModelInfo, + ModelModality, ResolvedRetryPolicy, RetryPolicyConfig, StreamChunk, @@ -51,6 +52,8 @@ export interface ReplayModelConfig { description?: string /** Optional positive integer context capacity published by the replay adapter. */ contextWindow?: number + /** Optional declared input modalities, so a scenario can exercise capability gates (e.g. image-capable `read_image`). */ + inputModalities?: readonly ModelModality[] /** * Optional per-request output cap the replay route materializes when callers * omit one, so replay reconstructs the request header a live catalog produced. @@ -581,6 +584,7 @@ class ReplayAdapter extends LlmAdapter { id: model.id, name: model.name ?? model.id, ...model.description === undefined ? {} : { description: model.description }, + ...model.inputModalities === undefined ? {} : { inputModalities: [...model.inputModalities] }, }))) } @@ -594,6 +598,9 @@ class ReplayAdapter extends LlmAdapter { id: model, name: configuredModel?.name ?? model, ...configuredModel?.description === undefined ? {} : { description: configuredModel.description }, + ...configuredModel?.inputModalities === undefined + ? {} + : { inputModalities: [...configuredModel.inputModalities] }, ...configuredModel?.contextWindow === undefined ? {} : { context: { contextWindow: configuredModel.contextWindow } }, @@ -783,11 +790,28 @@ export interface Config { paceMs?: number } +function validateConfiguredModalities(providers: ReplayProviderConfig[] | undefined): void { + for (const provider of providers ?? []) { + for (const model of provider.models ?? []) { + const modalities: unknown = model.inputModalities + if (modalities === undefined) continue + if (!Array.isArray(modalities) + || !modalities.every((modality: unknown) => modality === 'text' || modality === 'image')) { + throw new Error( + `llm-replay: provider "${provider.id}" model "${model.id}" inputModalities ` + + 'must be an array containing only "text" and "image"', + ) + } + } + } +} + export function apply(ctx: Context, config: Config = {}): void { const file = config.file ?? process.env.DSH_SNAPSHOT_FILE if (file === undefined || file.length === 0) { throw new Error('llm-replay: a fixture path is required (Config.file or $DSH_SNAPSHOT_FILE)') } + validateConfiguredModalities(config.providers) const overrideFile = config.overrideFile ?? process.env.DSH_SNAPSHOT_OVERRIDE const childEnv = process.env.DSH_SNAPSHOT_CHILD_FILES const childFiles = config.childFiles diff --git a/packages/support/llm-replay/src/invariant.ts b/packages/support/llm-replay/src/invariant.ts index 36a3f8eeca..64fc06a262 100644 --- a/packages/support/llm-replay/src/invariant.ts +++ b/packages/support/llm-replay/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-llm-replay' diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 4b163fdd95..defcfa7745 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -2,11 +2,12 @@ import { existsSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' import { afterEach, beforeEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { CompactionId } from '@deepseek-ai/dsh-compact' import LlmService, { CallId, createUserMessage, GenerateOptions, LlmAdapter, StreamChunk } from '@deepseek-ai/dsh-llm' import { + type Config, type ReplayEntry, type SessionScript, apply, @@ -595,6 +596,7 @@ describe('installLlmReplay (through the real LlmService)', () => { { id: 'flash', contextWindow: 128_000, + inputModalities: ['text', 'image'], defaultMaxTokens: 64_000, reasoningEfforts: ['off', 'max'], defaultReasoningEffort: 'max', @@ -611,18 +613,20 @@ describe('installLlmReplay (through the real LlmService)', () => { { id: 'empty', name: 'empty' }, ]) await expect(ctx.llm.listModels('deepseek')).resolves.toEqual([ - { provider: 'deepseek', id: 'flash', name: 'flash' }, + { provider: 'deepseek', id: 'flash', name: 'flash', inputModalities: ['text', 'image'] }, { provider: 'deepseek', id: 'pro', name: 'Pro', description: 'Larger model' }, ]) await expect(ctx.llm.listModels('empty')).resolves.toEqual([]) await expect(ctx.llm.resolveModelInfo('deepseek', 'flash')).resolves.toMatchObject({ context: { contextWindow: 128_000 }, + inputModalities: ['text', 'image'], defaultMaxTokens: 64_000, reasoning: { efforts: [{ id: 'off', name: 'off' }, { id: 'max', name: 'max' }], defaultEffort: 'max', }, }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('inputModalities') await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context') // Efforts without a configured default preserve the provider's own default. await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.toMatchObject({ @@ -1111,11 +1115,31 @@ describe('apply (the plugin entry)', () => { writeFileSync(file, sessionJsonl(TEXT_CHUNKS.map((c, i) => chunkEvent(i + 1, 1, 1, c))), 'utf8') const ctx = new Context() await ctx.plugin(LlmService) - apply(ctx, { file, providers: [{ id: 'm', models: [{ id: 'm' }] }], paceMs: 1 }) - expect(ctx.llm.listProviders()).toEqual([{ id: 'm', name: 'm' }]) + apply(ctx, { + file, + providers: [ + { id: 'm', models: [{ id: 'm', inputModalities: ['image'] }, { id: 'text' }] }, + { id: 'empty' }, + ], + paceMs: 1, + }) + expect(ctx.llm.listProviders()).toEqual([{ id: 'm', name: 'm' }, { id: 'empty', name: 'empty' }]) + await expect(ctx.llm.resolveModelInfo('m', 'm')).resolves.toMatchObject({ inputModalities: ['image'] }) expect(await drain(ctx.llm.stream({ provider: 'm', model: 'm', messages: [] }))).toEqual(TEXT_CHUNKS) }) + it.each([ + ['a string', 'image'], + ['an unknown modality', ['audio']], + ])('rejects inputModalities configured as %s during load', (_case, inputModalities) => { + const ctx = new Context() + const providers = [{ id: 'm', models: [{ id: 'm', inputModalities }] }] as unknown as + NonNullable + expect(() => { apply(ctx, { file, providers }) }).toThrow( + 'llm-replay: provider "m" model "m" inputModalities must be an array containing only "text" and "image"', + ) + }) + it('falls back to $DSH_SNAPSHOT_FILE / $DSH_SNAPSHOT_OVERRIDE when config is empty', async () => { writeFileSync(file, sessionJsonl([]), 'utf8') const overrideFile = join(dir, 'replay.override.json') diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index 5cb8ef96a2..9f43ca3a01 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/support/loader-smoke" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -29,17 +36,17 @@ "tsx": "^4.22.4" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/support/loader-smoke/src/agent-turn.ts b/packages/support/loader-smoke/src/agent-turn.ts index ea3a65d725..5638445cbe 100644 --- a/packages/support/loader-smoke/src/agent-turn.ts +++ b/packages/support/loader-smoke/src/agent-turn.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-loader-smoke/agent-turn */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage, type TokenUsage } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' diff --git a/packages/support/loader-smoke/src/invariant.ts b/packages/support/loader-smoke/src/invariant.ts index 1e3cc54b81..36e8d646de 100644 --- a/packages/support/loader-smoke/src/invariant.ts +++ b/packages/support/loader-smoke/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-loader-smoke' diff --git a/packages/support/loader-smoke/tests/agent-turn.spec.ts b/packages/support/loader-smoke/tests/agent-turn.spec.ts index 481b4ef566..4f04efc303 100644 --- a/packages/support/loader-smoke/tests/agent-turn.spec.ts +++ b/packages/support/loader-smoke/tests/agent-turn.spec.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { SessionEvent } from '@deepseek-ai/dsh-session' import { describe, expect, it, vi } from 'vitest' import { runFixtureTurn } from '../src/agent-turn.ts' diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index 92e095ffa9..cd40629948 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tasks-local", "description": "Process-local implementation of the DeepSeek Harness background task registry seam", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/tasks/tasks-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,12 +32,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-scope": "^0.0.1", - "@deepseek-ai/dsh-tasks": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-scope": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -40,6 +47,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index c85c3af713..6366024f74 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -9,13 +9,16 @@ * @module @deepseek-ai/dsh-tasks-local */ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { AnonymousEntries, ScopedLayers, scopeOf } from '@deepseek-ai/dsh-scope' import type { ScopeLayer } from '@deepseek-ai/dsh-scope' import { deadline, timeoutOf } from '@deepseek-ai/dsh-timeout' import { TaskService, TaskId } from '@deepseek-ai/dsh-tasks' -import type { TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus } from '@deepseek-ai/dsh-tasks' +import type { + TaskDoneListener, TaskKind, TaskOutcome, TaskRead, TaskSnapshot, TaskStart, TaskStatus, + TasksChangedListener, +} from '@deepseek-ai/dsh-tasks' /** Timeout code that distinguishes a bounded wait from caller cancellation. */ export const TASK_WAIT_TIMEOUT = 'TASK_WAIT_TIMEOUT' @@ -60,9 +63,10 @@ function isTerminal(status: TaskStatus): boolean { class TaskLayer implements ScopeLayer { readonly surfaces = new AnonymousEntries() readonly listeners = new AnonymousEntries() + readonly changed = new AnonymousEntries() isEmpty(): boolean { - return this.surfaces.isEmpty() && this.listeners.isEmpty() + return this.surfaces.isEmpty() && this.listeners.isEmpty() && this.changed.isEmpty() } } @@ -147,6 +151,9 @@ export class LocalTaskService extends TaskService { this.settle(task, { status: 'failed', detail: String(error) }) }, ) + // Registration is complete and cannot fail from here, so the visible set + // has genuinely changed. + this.notifyChanged(task.owner) return id } @@ -184,6 +191,7 @@ export class LocalTaskService extends TaskService { task.cancel(reason) task.status = 'stopping' task.reported = true + this.notifyChanged(task.owner) return 'requested' } @@ -245,6 +253,14 @@ export class LocalTaskService extends TaskService { ) } + onTasksChanged(listener: TasksChangedListener): () => void { + return this.layers.effect( + this.ctx, + layer => layer.changed.append(listener), + { label: 'tasks.onTasksChanged()' }, + ) + } + attachSurface(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) @@ -318,6 +334,35 @@ export class LocalTaskService extends TaskService { } } + /** + * The change observers that own `owner`'s updates, resolved exactly like + * {@link listenersFor}: the global layer — a host composition's own carrier, + * which serves every owner — then each scoped layer along the owner's chain. + * An observer outside that chain belongs to another composition and would + * otherwise be told about agents it does not compose. + * @param owner - the owner whose visible set moved, or undefined for unowned work. + * @returns the observers to notify, in registration order per layer. + */ + private *changedFor(owner?: Agent): IterableIterator { + yield* this.layers.global.changed.values() + const scope = owner === undefined ? undefined : scopeOf(owner.ctx) + for (const layer of this.layers.chainLayers(scope)) yield* layer.changed.values() + } + + /** + * Announce that one owner's visible set changed. Each listener is contained + * so an observer cannot break a lifecycle commit that already happened. + */ + private notifyChanged(owner: Agent | undefined): void { + for (const listener of this.changedFor(owner)) { + try { + listener(owner) + } catch (error: unknown) { + this.selfCtx.logger.warn(`tasks: onTasksChanged listener threw: ${String(error)}`) + } + } + } + /** * Record the first terminal outcome, notify contained listeners, and release * waiters. First-wins preserves a teardown force-failure against late producer @@ -347,6 +392,7 @@ export class LocalTaskService extends TaskService { task.waitResolvers.clear() for (const resolveWait of waitResolvers) resolveWait() task.markSettled() + this.notifyChanged(task.owner) } /** @@ -379,6 +425,9 @@ export class LocalTaskService extends TaskService { this.cancelForTeardown(owned, 'owner disposed') await Promise.all(owned.map(task => task.settled)) for (const task of owned) this.store.delete(task.id) + // Removal is the one visible-set change no per-task record carries, so it + // must be announced here or an observer keeps the dropped rows forever. + if (owned.length > 0) this.notifyChanged(owner) } /** @@ -392,7 +441,14 @@ export class LocalTaskService extends TaskService { const all = [...this.store.values()] this.cancelForTeardown(all, 'tasks service disposed') await Promise.all(all.map(task => task.settled)) + // Distinct owners whose records just disappeared. A change observer files + // into the layer of the context that registered it, so a consumer mounted + // outside this service — the api-proxy carrier registers from the mux + // stream — is still reachable here. Without this it keeps the rows it last + // received after a registry reload. + const emptied = new Set(all.map(task => task.owner)) this.store.clear() + for (const owner of emptied) this.notifyChanged(owner) // Detach cross-fiber owner effects after the shared store is quiescent. const ownerCleanups = [...this.ownerCleanups.values()] this.ownerCleanups.clear() @@ -410,6 +466,10 @@ export class LocalTaskService extends TaskService { try { task.cancel(reason) task.status = 'stopping' + // Teardown reaches settlement only after the producer releases, which a + // slow stop can defer; announcing the transition here is what keeps an + // observer from showing `running` for that whole window. + this.notifyChanged(task.owner) } catch (error: unknown) { const detail = `cancel threw during teardown; work may be orphaned: ${String(error)}` this.selfCtx.logger.warn(`tasks: cancel of ${task.id} threw during teardown; task record forced failed and work may be orphaned: ${String(error)}`) diff --git a/packages/tasks/tasks-local/src/invariant.ts b/packages/tasks/tasks-local/src/invariant.ts index 22f2b4fecd..21d00ed155 100644 --- a/packages/tasks/tasks-local/src/invariant.ts +++ b/packages/tasks/tasks-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tasks-local' diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 2c3fd11ef2..b4f0ced798 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, expectTypeOf, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -847,3 +847,150 @@ describe('LocalTaskService disposal', () => { expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface serves this agent') }) }) + +describe('LocalTaskService.onTasksChanged', () => { + it('fires after registration, the stopping transition, and settlement', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'alice') + ctx.agents.register(owner) + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + + const p = producer({ owner }) + const id = ctx.tasks.start(p.spec) + // Registration is announced only once the record is readable. + expect(seen).toEqual(['alice']) + expect(ctx.tasks.list(owner)).toHaveLength(1) + + expect(ctx.tasks.kill(id, owner)).toBe('requested') + expect(seen).toEqual(['alice', 'alice']) + expect(ctx.tasks.get(id, owner).status).toBe('stopping') + + p.settle({ status: 'killed' }) + await tick() + expect(seen).toEqual(['alice', 'alice', 'alice']) + expect(ctx.tasks.get(id, owner).status).toBe('killed') + await disposeAgentScope(owner) + }) + + it('reports an unowned change as undefined, since every caller can see it', async () => { + const ctx = await harness() + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + + ctx.tasks.start(producer().spec) + expect(seen).toEqual([undefined]) + }) + + it('announces the owner-disposal removal, and stays silent when that owner had none', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'alice') + const bystander = stubAgent(ctx, 'bob') + ctx.agents.register(owner) + ctx.agents.register(bystander) + const p = producer({ owner }) + ctx.tasks.start(p.spec) + + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + p.settle({ status: 'completed' }) + await tick() + expect(seen).toEqual(['alice']) + + // Disposing an owner with no records changes no visible set. + await disposeAgentScope(bystander) + expect(seen).toEqual(['alice']) + + await disposeAgentScope(owner) + expect(seen).toEqual(['alice', 'alice']) + expect(ctx.tasks.list(owner)).toEqual([]) + }) + + it('contains a throwing listener so the lifecycle commit still stands', async () => { + const ctx = await harness() + const warn = vi.spyOn(ctx.logger, 'warn').mockImplementation(() => {}) + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(() => { throw new Error('observer boom') }) + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + + const id = ctx.tasks.start(producer().spec) + expect(id).toBe('bash-1') + expect(seen).toEqual([undefined]) + expect(warn).toHaveBeenCalledWith(expect.stringContaining('onTasksChanged listener threw')) + }) + + it('unregisters through its disposer and with its fiber (HMR safety)', async () => { + const ctx = await harness() + const seen: number[] = [] + const detach = ctx.tasks.onTasksChanged(() => void seen.push(1)) + const fiber = await ctx.plugin(Object.assign((inner: Context) => { + inner.tasks.onTasksChanged(() => void seen.push(2)) + }, { inject: ['tasks'] })) + + ctx.tasks.start(producer().spec) + expect(seen).toEqual([1, 2]) + + detach() + detach() // second call of the same disposer is a no-op + ctx.tasks.start(producer().spec) + expect(seen).toEqual([1, 2, 2]) + + await fiber.dispose() + ctx.tasks.start(producer().spec) + expect(seen).toEqual([1, 2, 2]) + }) +}) + +describe('LocalTaskService teardown change notifications', () => { + it('announces the stopping transition during owner teardown, before settlement', async () => { + const ctx = await harness() + const owner = stubAgent(ctx, 'alice') + ctx.agents.register(owner) + const p = producer({ owner }) + const id = ctx.tasks.start(p.spec) + + const statuses: (string | undefined)[] = [] + ctx.tasks.onTasksChanged((changed) => { + statuses.push(changed === undefined ? undefined : ctx.tasks.list(changed)[0]?.status) + }) + + // A slow producer keeps teardown parked between cancel and settlement; + // an observer must not be left showing `running` for that whole window. + const disposal = disposeAgentScope(owner) + await tick() + expect(statuses).toEqual(['stopping']) + + p.settle({ status: 'killed' }) + await disposal + // Settlement, then the removal that empties the visible set. + expect(statuses).toEqual(['stopping', 'killed', undefined]) + expect(ctx.tasks.list(owner)).toEqual([]) + void id + }) + + it('announces the emptied set to a listener registered outside this service (reload safety)', async () => { + const ctx = new Context() + await ctx.plugin(AgentRegistry) + const fiber = await ctx.plugin(LocalTaskService) + ctx.tasks.attachSurface('test-surface') + + // The api-proxy carrier registers from its own stream context, not the + // registry's fiber, so it is still listening when the registry unloads. + const seen: (string | undefined)[] = [] + ctx.tasks.onTasksChanged(changed => void seen.push(changed?.id)) + let settle!: (outcome: TaskOutcome) => void + ctx.tasks.start({ + kind: 'bash', + label: 'sleep 600', + run: () => ({ + cancel() { settle({ status: 'killed' }) }, + done: new Promise((resolve) => { settle = resolve }), + }), + }) + seen.length = 0 + + await fiber.dispose() + // stopping (teardown cancel), settlement, then the final empty set. + expect(seen).toEqual([undefined, undefined, undefined]) + }) +}) diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index 95bb5a3889..7c7ceba672 100644 --- a/packages/tasks/tasks/README.i18n.yaml +++ b/packages/tasks/tasks/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/tasks/tasks/README.md -README.md: f23a93e3cb1fc5aad5bad053f832bb66baa8eb64 -README.zh.md: c3ea9125c4db9f2d86722cbf490b4f0eecfbe25a +README.md: 053e407d2e28cb175ebe9de15c7e16ef04e95cb8 +README.zh.md: dd39b1098cd01d7db9db6e49210c2b1149b37ecb diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index f23a93e3cb..053e407d2e 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -12,9 +12,10 @@ The background task registry contract (`ctx.tasks`). The abstract `TaskService` - `kill(id, caller?, reason?)` invokes producer cancellation before changing status. A cancellation throw leaves the task running; success changes it to `stopping` and marks terminal delivery reported. - `wait(id, timeoutMs, caller?, signal?)` returns a terminal snapshot or the live snapshot at timeout. Aborting stops only the wait; settlement wins once it has committed terminal delivery to that waiter. - `onTaskDone(listener)` observes each terminal record with the exact owner. Listener throws and rejections are contained; listener work is not awaited. +- `onTasksChanged(listener)` observes visible-set changes — registration, every stopping transition (teardown's included, before it awaits a slow producer), settlement, owner-disposal removal, and the emptying service disposal commits — carrying only the owner whose set moved, or `undefined` when an unowned task changed and every caller's set moved with it. It is owner-granular because removal is a change no per-task record can express, and it is not a superset of `onTaskDone`: it carries no delivery meaning and marks nothing reported. The registration binds to the calling fiber, so an observer mounted outside the registry still sees the disposal emptying. - `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when no attached surface serves the spec's owner. -Both registrations are owner-relative, because one registry serves every composition in the process. A surface or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. So a composition that loads no control surface cannot start background work on the strength of another composition's controls, and one settlement notifies only the listeners its owner's composition registered. +All three registrations are owner-relative, because one registry serves every composition in the process. A surface or listener registered from an unscoped context serves every owner; one registered under an agent composition's scope serves exactly the agents composed under it. So a composition that loads no control surface cannot start background work on the strength of another composition's controls, and one settlement notifies only the listeners its owner's composition registered. Owned access compares the task's `SessionId` with the caller's. Ids such as `bash-1` are predictable, so this fence is the boundary. Unowned tasks are open to callers and last until service disposal. diff --git a/packages/tasks/tasks/README.zh.md b/packages/tasks/tasks/README.zh.md index c3ea9125c4..dd39b1098c 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -12,9 +12,10 @@ - `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 - `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。 - `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。 +- `onTasksChanged(listener)` 观察可见集合的变化——注册、每一次转入 stopping(包括 teardown 在等待缓慢生产者之前的那一次)、结算、owner 销毁时的移除,以及服务销毁提交的清空——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。注册绑定的是调用方 fiber,因此挂在注册表之外的观察者仍能收到销毁时的清空。 - `attachSurface(name)` 在其 effect 生命周期内声明控制表层。当没有任何已附加的表层服务于 spec 的所有者时,`start()` 会在生产方执行前失败。 -这两类注册都是相对于所有者的,因为一个注册表要服务进程内的每一套组合。从不带 scope 的上下文注册的表层或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,则恰好服务于在该组合下组合出的 agent。因此,未加载任何控制表层的组合无法借另一套组合的控制工具启动后台工作,而一次结算也只会通知其所有者所属组合注册的监听器。 +这三类注册都是相对于所有者的,因为一个注册表要服务进程内的每一套组合。从不带 scope 的上下文注册的表层或监听器服务于每个所有者;在某套 agent 组合的 scope 下注册的,则恰好服务于在该组合下组合出的 agent。因此,未加载任何控制表层的组合无法借另一套组合的控制工具启动后台工作,而一次结算也只会通知其所有者所属组合注册的监听器。 有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。 diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index eea8e2af6b..8c968b743b 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tasks", "description": "Background task registry (ctx.tasks) for the DeepSeek Harness — shared ids, owner isolation, polling, cancellation, and completion listeners for long-running tool work", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/tasks/tasks" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -15,27 +22,32 @@ "types": "./lib/types/invariant.d.ts", "default": "./lib/invariant.js" }, + "./brand": { + "types": "./lib/types/brand.d.ts", + "default": "./lib/types/brand.js" + }, "./src/*": "./src/*", "./package.json": "./package.json" }, "files": [ "lib/index.js", "lib/invariant.js", + "lib/types/**/*.js", "lib/types/**/*.d.ts" ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-brand": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/tasks/tasks/src/brand.ts b/packages/tasks/tasks/src/brand.ts new file mode 100644 index 0000000000..b9596514b3 --- /dev/null +++ b/packages/tasks/tasks/src/brand.ts @@ -0,0 +1,28 @@ +/** + * dsh-tasks' owned branded id, carried across the registry, the model-facing + * control surface, and the client wire. + * + * It lives in its own leaf because the package root and `./types` both reach + * `dsh-agent` through the owner and listener signatures, which a Client program + * cannot resolve even as a type. A browser-safe consumer imports the id here; + * `Branded` itself comes from the zero-dependency `@deepseek-ai/dsh-brand`. + * + * @module @deepseek-ai/dsh-tasks/brand + */ + +import type { Branded } from '@deepseek-ai/dsh-brand' + +/** + * Identifies a background task. The registry generates `-N`; predictable + * ids rely on owner authorization rather than secrecy. + */ +export type TaskId = Branded<'TaskId'> + +/** + * Brand a string as a {@link TaskId}. + * @param id - the raw task-id string (the registry generates `-N`). + * @returns the same string, branded; no validation is performed. + */ +export function TaskId(id: string): TaskId { + return id as TaskId +} diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 8c49ec8445..06bb2e38fb 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -6,9 +6,11 @@ * @module @deepseek-ai/dsh-tasks */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' -import type { TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart } from './types.ts' +import type { + TaskDoneListener, TaskId, TaskRead, TaskSnapshot, TaskStart, TasksChangedListener, +} from './types.ts' export { TaskId } from './types.ts' export type { @@ -21,9 +23,10 @@ export type { TaskSnapshot, TaskStart, TaskStatus, + TasksChangedListener, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { tasks: TaskService } @@ -134,6 +137,30 @@ export abstract class TaskService extends Service { */ abstract onTaskDone(listener: TaskDoneListener): () => void + /** + /** + * Register an effect-scoped observer of visible-set changes. It fires after + * every commit that changes what {@link list} returns for that owner — + * registration, every stopping transition (including the one teardown + * performs before it awaits a slow producer), settlement, owner-disposal + * removal, and the emptying that service disposal commits — so an observer + * re-reads rather than accumulating deltas. + * + * Delivery is owner-relative on the same terms as {@link onTaskDone}: an + * observer registered from an unscoped context — a host composition's own + * carrier — sees every owner, while one registered under an agent + * composition's scope sees exactly the agents composed under it. + * + * This is not a superset of {@link onTaskDone}: that one delivers the terminal + * record under first-wins semantics a control surface couples to notice + * delivery, while this one carries no delivery meaning and marks nothing + * reported. Listeners are contained and never awaited. + * @param listener - receives the owner whose visible set changed, or + * `undefined` when an unowned task changed and every caller's set did. + * @returns disposer that unregisters the listener. + */ + abstract onTasksChanged(listener: TasksChangedListener): () => void + /** * Attach an effect-scoped surface that can read and stop tasks. It serves the * owners its registering context's scope covers, and {@link start} refuses an diff --git a/packages/tasks/tasks/src/invariant.ts b/packages/tasks/tasks/src/invariant.ts index a633213607..99f850e929 100644 --- a/packages/tasks/tasks/src/invariant.ts +++ b/packages/tasks/tasks/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned background-task snapshot invariants. @module @deepseek-ai/dsh-tasks/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { TaskSnapshot } from './types.ts' diff --git a/packages/tasks/tasks/src/types.ts b/packages/tasks/tasks/src/types.ts index d722f3fce6..a05b75817c 100644 --- a/packages/tasks/tasks/src/types.ts +++ b/packages/tasks/tasks/src/types.ts @@ -4,24 +4,11 @@ * @module @deepseek-ai/dsh-tasks/types */ -import type { Branded } from '@deepseek-ai/dsh-brand' import type { Agent } from '@deepseek-ai/dsh-agent' import type { SessionId } from '@deepseek-ai/dsh-session' +import type { TaskId } from './brand.ts' -/** - * Identifies a background task. The registry generates `-N`; predictable - * ids rely on owner authorization rather than secrecy. - */ -export type TaskId = Branded<'TaskId'> - -/** - * Brand a string as a {@link TaskId}. - * @param id - the raw task-id string (the registry generates `-N`). - * @returns the same string, branded; no validation is performed. - */ -export function TaskId(id: string): TaskId { - return id as TaskId -} +export { TaskId } from './brand.ts' /** * Task lifecycle: `running`, optionally `stopping`, then exactly one terminal @@ -157,3 +144,14 @@ export type TaskDoneListener = ( snapshot: TaskSnapshot, owner: Agent | undefined, ) => void | PromiseLike + +/** + * Observation callback for a change to what one owner's {@link TaskService.list} + * would return. It is owner-granular rather than task-granular because the + * change may be a removal, which no per-task record can express, and because + * its consumers re-read the whole visible set anyway. + * + * An `undefined` owner means an unowned task changed, so every caller's visible + * set changed with it. + */ +export type TasksChangedListener = (owner: Agent | undefined) => void diff --git a/packages/tasks/tasks/tests/invariant.spec.ts b/packages/tasks/tasks/tests/invariant.spec.ts index e23609df5d..db7ca8bb0e 100644 --- a/packages/tasks/tasks/tests/invariant.spec.ts +++ b/packages/tasks/tasks/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { SessionId } from '@deepseek-ai/dsh-session' import TaskService, { TaskId } from '@deepseek-ai/dsh-tasks' diff --git a/packages/tasks/tasks/tests/service.spec.ts b/packages/tasks/tasks/tests/service.spec.ts index 9c6e2654e3..c9a3316d94 100644 --- a/packages/tasks/tasks/tests/service.spec.ts +++ b/packages/tasks/tasks/tests/service.spec.ts @@ -1,8 +1,10 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { TaskId, TaskService } from '@deepseek-ai/dsh-tasks' -import type { TaskDoneListener, TaskRead, TaskSnapshot, TaskStart } from '@deepseek-ai/dsh-tasks' +import type { + TaskDoneListener, TaskRead, TaskSnapshot, TaskStart, TasksChangedListener, +} from '@deepseek-ai/dsh-tasks' /** * Minimal concrete registry: one canned record. The Service Definition owns the contract @@ -50,6 +52,10 @@ class StubTaskService extends TaskService { return () => {} } + onTasksChanged(_listener: TasksChangedListener): () => void { + return () => {} + } + attachSurface(_name: string): () => void { return () => {} } @@ -70,6 +76,8 @@ describe('TaskService seam', () => { await expect(ctx.tasks.wait(id, 5)).resolves.toMatchObject({ id }) const detachListener = ctx.tasks.onTaskDone(() => {}) detachListener() + const detachChanges = ctx.tasks.onTasksChanged(() => {}) + detachChanges() detachSurface() }) diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index dfffbf4855..c1ab0be9c4 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-tasks", "description": "Model-facing background task control tools (task_output, task_list, task_kill) over the ctx.tasks registry", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/tasks/tool-tasks" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,17 +32,17 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-retention": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tasks": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-retention": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tasks": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -47,6 +54,6 @@ "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/tasks/tool-tasks/src/index.ts b/packages/tasks/tool-tasks/src/index.ts index 720cb51f97..2ec5bdaff7 100644 --- a/packages/tasks/tool-tasks/src/index.ts +++ b/packages/tasks/tool-tasks/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-tool-tasks */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { boundContextSummary, createUserMessage, type ContentBlock } from '@deepseek-ai/dsh-llm' import { TextRetainer } from '@deepseek-ai/dsh-retention' import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/packages/tasks/tool-tasks/src/invariant.ts b/packages/tasks/tool-tasks/src/invariant.ts index cedad9dc1c..d1c9f77bd6 100644 --- a/packages/tasks/tool-tasks/src/invariant.ts +++ b/packages/tasks/tool-tasks/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-tasks' diff --git a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts index a213ca83e5..3221c20246 100644 --- a/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts +++ b/packages/tasks/tool-tasks/tests/tool-tasks.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 2c4490589f..0f080c5d33 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/todo/tool-todo" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -30,20 +37,20 @@ ], "license": "BSD-3-Clause", "dependencies": { - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-projection": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-projection": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -55,6 +62,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/todo/tool-todo/src/index.ts b/packages/todo/tool-todo/src/index.ts index 12e0d164c6..e92af70313 100644 --- a/packages/todo/tool-todo/src/index.ts +++ b/packages/todo/tool-todo/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-tool-todo */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { z as zod } from 'zod' import type { ZodType } from 'zod' import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/packages/todo/tool-todo/src/invariant.ts b/packages/todo/tool-todo/src/invariant.ts index 8c2a7aa7dd..f1b8c63066 100644 --- a/packages/todo/tool-todo/src/invariant.ts +++ b/packages/todo/tool-todo/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned durable todo-snapshot invariants. @module @deepseek-ai/dsh-tool-todo/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Session, SessionEvent } from '@deepseek-ai/dsh-session' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' diff --git a/packages/todo/tool-todo/tests/integration.spec.ts b/packages/todo/tool-todo/tests/integration.spec.ts index 568b0f764a..264731bdbc 100644 --- a/packages/todo/tool-todo/tests/integration.spec.ts +++ b/packages/todo/tool-todo/tests/integration.spec.ts @@ -1,6 +1,6 @@ import { createUserMessage } from '@deepseek-ai/dsh-llm' import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SessionId, type SessionEvent } from '@deepseek-ai/dsh-session' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' diff --git a/packages/todo/tool-todo/tests/invariant.spec.ts b/packages/todo/tool-todo/tests/invariant.spec.ts index ac3c76bf71..c38958df95 100644 --- a/packages/todo/tool-todo/tests/invariant.spec.ts +++ b/packages/todo/tool-todo/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SessionStore, { type Session, type SessionEvent } from '@deepseek-ai/dsh-session' import ToolRegistry from '@deepseek-ai/dsh-tools' import * as ToolTodo from '@deepseek-ai/dsh-tool-todo' diff --git a/packages/todo/tool-todo/tests/loader-composition.spec.ts b/packages/todo/tool-todo/tests/loader-composition.spec.ts index 572254e348..41728d93e1 100644 --- a/packages/todo/tool-todo/tests/loader-composition.spec.ts +++ b/packages/todo/tool-todo/tests/loader-composition.spec.ts @@ -6,9 +6,9 @@ import { tmpdir } from 'node:os' import { join } 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 Include from '@cordisjs/plugin-include' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import Include from '@deepseek-ai/cordis-plugin-include' import { CallId } from '@deepseek-ai/dsh-llm' import { Session, SessionId } from '@deepseek-ai/dsh-session' import AgentRegistry, { Inbox } from '@deepseek-ai/dsh-agent' diff --git a/packages/todo/tool-todo/tests/projection.spec.ts b/packages/todo/tool-todo/tests/projection.spec.ts index 0b214fc311..b53e9dd587 100644 --- a/packages/todo/tool-todo/tests/projection.spec.ts +++ b/packages/todo/tool-todo/tests/projection.spec.ts @@ -8,7 +8,7 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' import { createUserMessage } from '@deepseek-ai/dsh-llm' diff --git a/packages/todo/tool-todo/tests/tool-todo.spec.ts b/packages/todo/tool-todo/tests/tool-todo.spec.ts index 12d1f5f665..5e79b1a69a 100644 --- a/packages/todo/tool-todo/tests/tool-todo.spec.ts +++ b/packages/todo/tool-todo/tests/tool-todo.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 5ffb933214..53e711b3a7 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/typert/generator" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -34,14 +41,14 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-tool-cordis": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "zod": "^4.4.3" } } diff --git a/packages/typert/generator/src/analyzer.ts b/packages/typert/generator/src/analyzer.ts index e5ade888cf..358edceb66 100644 --- a/packages/typert/generator/src/analyzer.ts +++ b/packages/typert/generator/src/analyzer.ts @@ -653,7 +653,7 @@ class FaceAnalyzer { for (const statement of sourceFile.statements) { if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name) - || statement.name.text !== 'cordis' + || statement.name.text !== '@deepseek-ai/cordis' || statement.body === undefined || !ts.isModuleBlock(statement.body)) continue for (const member of statement.body.statements) { @@ -2545,7 +2545,7 @@ function sourceFileHasSurface(sourceFile: ts.SourceFile): boolean { } if (!ts.isModuleDeclaration(statement) || !ts.isStringLiteral(statement.name) - || statement.name.text !== 'cordis' + || statement.name.text !== '@deepseek-ai/cordis' || statement.body === undefined || !ts.isModuleBlock(statement.body)) continue if (statement.body.statements.some(member => ts.isInterfaceDeclaration(member) diff --git a/packages/typert/generator/src/invariant.ts b/packages/typert/generator/src/invariant.ts index e4da20785f..1c153f74f4 100644 --- a/packages/typert/generator/src/invariant.ts +++ b/packages/typert/generator/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-typert-generator' diff --git a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap index bcc28cd8b2..da7510736b 100644 --- a/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap +++ b/packages/typert/generator/tests/__snapshots__/type-model.spec.ts.snap @@ -407,7 +407,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/host:packages/host/src/index.ts#Agent#id@480", + "id": "@fixture/host:packages/host/src/index.ts#Agent#id@493", "kind": "property", "location": { "column": 3, @@ -426,7 +426,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/host:packages/host/src/index.ts#Agent#state@502", + "id": "@fixture/host:packages/host/src/index.ts#Agent#state@515", "kind": "property", "location": { "column": 3, @@ -446,7 +446,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Read the public display label.", - "id": "@fixture/host:packages/host/src/index.ts#Agent#label@735", + "id": "@fixture/host:packages/host/src/index.ts#Agent#label@748", "jsDoc": "/** Read the public display label. */", "kind": "getter", "location": { @@ -472,7 +472,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Accept a public display label.", - "id": "@fixture/host:packages/host/src/index.ts#Agent#label@823", + "id": "@fixture/host:packages/host/src/index.ts#Agent#label@836", "jsDoc": "/** Accept a public display label. */", "kind": "setter", "location": { @@ -507,7 +507,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Run one typed input.", - "id": "@fixture/host:packages/host/src/index.ts#Agent#run@902", + "id": "@fixture/host:packages/host/src/index.ts#Agent#run@915", "jsDoc": "/** Run one typed input. */", "kind": "method", "location": { @@ -598,7 +598,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Report readiness.", - "id": "@fixture/host:packages/host/src/index.ts#AliasedService#ready@1181", + "id": "@fixture/host:packages/host/src/index.ts#AliasedService#ready@1194", "jsDoc": "/** Report readiness. */", "kind": "method", "location": { @@ -651,7 +651,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Report readiness.", - "id": "@fixture/host:packages/host/src/index.ts#DefaultOnlyService#ready@1404", + "id": "@fixture/host:packages/host/src/index.ts#DefaultOnlyService#ready@1417", "jsDoc": "/** Report readiness. */", "kind": "method", "location": { @@ -704,7 +704,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Inspect one agent without flattening its generic state.", - "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspect@1767", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspect@1780", "jsDoc": "/** Inspect one agent without flattening its generic state. */", "kind": "method", "location": { @@ -747,7 +747,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Keep an npm-owned type as External.", - "id": "@fixture/host:packages/host/src/index.ts#DemoService#acceptsExternal@1965", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#acceptsExternal@1978", "jsDoc": "/** Keep an npm-owned type as External. */", "kind": "method", "location": { @@ -782,7 +782,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Accept a developer-authored enum without flattening it.", - "id": "@fixture/host:packages/host/src/index.ts#DemoService#setPhase@2102", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#setPhase@2115", "jsDoc": "/** Accept a developer-authored enum without flattening it. */", "kind": "method", "location": { @@ -817,7 +817,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Exercise every retained type-graph shape from a public boundary.", - "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspectSyntax@2234", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspectSyntax@2247", "jsDoc": "/** Exercise every retained type-graph shape from a public boundary. */", "kind": "method", "location": { @@ -852,7 +852,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": true, "description": "Preserve async source metadata without changing its type signature.", - "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspectAsync@2369", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#inspectAsync@2382", "jsDoc": "/** Preserve async source metadata without changing its type signature. */", "kind": "method", "location": { @@ -887,7 +887,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Retain an authored binding-pattern parameter.", - "id": "@fixture/host:packages/host/src/index.ts#DemoService#destructure@2496", + "id": "@fixture/host:packages/host/src/index.ts#DemoService#destructure@2509", "jsDoc": "/** Retain an authored binding-pattern parameter. */", "kind": "method", "location": { @@ -2816,7 +2816,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/host/src/index.ts:116:31#1#ready@3038", + "id": "type:packages/host/src/index.ts:116:31#1#ready@3064", "kind": "property", "location": { "column": 33, @@ -2919,7 +2919,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/host/src/index.ts:12:43#1#ready@387", + "id": "type:packages/host/src/index.ts:12:43#1#ready@400", "kind": "property", "location": { "column": 45, @@ -3037,7 +3037,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/host/src/index.ts:139:31#1#ready@3476", + "id": "type:packages/host/src/index.ts:139:31#1#ready@3515", "kind": "property", "location": { "column": 33, @@ -3207,7 +3207,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "name": "Service", "target": { "kind": "external", - "module": "cordis", + "module": "@deepseek-ai/cordis", "name": "Service", "subpath": ".", }, @@ -3224,7 +3224,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "name": "Service", "target": { "kind": "external", - "module": "cordis", + "module": "@deepseek-ai/cordis", "name": "Service", "subpath": ".", }, @@ -3241,7 +3241,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "name": "Service", "target": { "kind": "external", - "module": "cordis", + "module": "@deepseek-ai/cordis", "name": "Service", "subpath": ".", }, @@ -3265,7 +3265,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/host/src/index.ts:68:24#1#ready@1790", + "id": "type:packages/host/src/index.ts:68:24#1#ready@1803", "kind": "property", "location": { "column": 26, @@ -5711,7 +5711,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "line": 101, }, "members": [ - "@fixture/host:packages/host/src/index.ts#AliasedService#ready@1181", + "@fixture/host:packages/host/src/index.ts#AliasedService#ready@1194", ], "summary": "Service exported only through a non-default alias.", "symbol": "@fixture/host:packages/host/src/index.ts#AliasedService", @@ -5736,7 +5736,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "line": 102, }, "members": [ - "@fixture/host:packages/host/src/index.ts#DefaultOnlyService#ready@1404", + "@fixture/host:packages/host/src/index.ts#DefaultOnlyService#ready@1417", ], "summary": "Service exported only through the package default.", "symbol": "@fixture/host:packages/host/src/index.ts#DefaultOnlyService", @@ -5760,12 +5760,12 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "line": 100, }, "members": [ - "@fixture/host:packages/host/src/index.ts#DemoService#inspect@1767", - "@fixture/host:packages/host/src/index.ts#DemoService#acceptsExternal@1965", - "@fixture/host:packages/host/src/index.ts#DemoService#setPhase@2102", - "@fixture/host:packages/host/src/index.ts#DemoService#inspectSyntax@2234", - "@fixture/host:packages/host/src/index.ts#DemoService#inspectAsync@2369", - "@fixture/host:packages/host/src/index.ts#DemoService#destructure@2496", + "@fixture/host:packages/host/src/index.ts#DemoService#inspect@1780", + "@fixture/host:packages/host/src/index.ts#DemoService#acceptsExternal@1978", + "@fixture/host:packages/host/src/index.ts#DemoService#setPhase@2115", + "@fixture/host:packages/host/src/index.ts#DemoService#inspectSyntax@2247", + "@fixture/host:packages/host/src/index.ts#DemoService#inspectAsync@2382", + "@fixture/host:packages/host/src/index.ts#DemoService#destructure@2509", ], "summary": "Fixture service with generic, mapped, and truly external boundary types.", "symbol": "@fixture/host:packages/host/src/index.ts#DemoService", @@ -5827,7 +5827,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "abstract": false, "async": false, "description": "Return the host-owned object unchanged.", - "id": "@fixture/client:packages/client/src/index.ts#ClientBridge#reflect@1096", + "id": "@fixture/client:packages/client/src/index.ts#ClientBridge#reflect@1109", "jsDoc": "/** Return the host-owned object unchanged. */", "kind": "method", "location": { @@ -5888,7 +5888,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#agent@587", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#agent@600", "kind": "property", "location": { "column": 3, @@ -5907,7 +5907,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#inherited@632", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#inherited@645", "kind": "property", "location": { "column": 3, @@ -5926,7 +5926,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#importedAgent@666", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#importedAgent@679", "kind": "property", "location": { "column": 3, @@ -5945,7 +5945,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#importedAgentWithNamedArgument@739", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#importedAgentWithNamedArgument@752", "kind": "property", "location": { "column": 3, @@ -5964,7 +5964,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#namespaceAgent@821", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#namespaceAgent@834", "kind": "property", "location": { "column": 3, @@ -5983,7 +5983,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#defaultService@876", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#defaultService@889", "kind": "property", "location": { "column": 3, @@ -6002,7 +6002,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#payload@915", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#payload@928", "kind": "property", "location": { "column": 3, @@ -6021,7 +6021,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "@fixture/client:packages/client/src/index.ts#ClientView#phase@943", + "id": "@fixture/client:packages/client/src/index.ts#ClientView#phase@956", "kind": "property", "location": { "column": 3, @@ -6084,7 +6084,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/client/src/index.ts:11:48#1#ready@469", + "id": "type:packages/client/src/index.ts:11:48#1#ready@482", "kind": "property", "location": { "column": 50, @@ -6130,7 +6130,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/client/src/index.ts:15:29#1#ready@615", + "id": "type:packages/client/src/index.ts:15:29#1#ready@628", "kind": "property", "location": { "column": 31, @@ -6188,7 +6188,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/client/src/index.ts:17:57#1#ready@722", + "id": "type:packages/client/src/index.ts:17:57#1#ready@735", "kind": "property", "location": { "column": 59, @@ -6264,7 +6264,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/client/src/index.ts:19:39#1#ready@859", + "id": "type:packages/client/src/index.ts:19:39#1#ready@872", "kind": "property", "location": { "column": 41, @@ -6334,7 +6334,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "name": "Service", "target": { "kind": "external", - "module": "cordis", + "module": "@deepseek-ai/cordis", "name": "Service", "subpath": ".", }, @@ -6371,7 +6371,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro { "abstract": false, "async": false, - "id": "type:packages/client/src/index.ts:28:40#1#ready@1135", + "id": "type:packages/client/src/index.ts:28:40#1#ready@1148", "kind": "property", "location": { "column": 42, @@ -6477,7 +6477,7 @@ exports[`WorkspaceAnalyzer > builds independent face models with an explicit cro "line": 35, }, "members": [ - "@fixture/client:packages/client/src/index.ts#ClientBridge#reflect@1096", + "@fixture/client:packages/client/src/index.ts#ClientBridge#reflect@1109", ], "summary": "Client-face service.", "symbol": "@fixture/client:packages/client/src/index.ts#ClientBridge", diff --git a/packages/typert/generator/tests/cordis-catalog-contract.spec.ts b/packages/typert/generator/tests/cordis-catalog-contract.spec.ts index bde52505dc..e7d4acfba1 100644 --- a/packages/typert/generator/tests/cordis-catalog-contract.spec.ts +++ b/packages/typert/generator/tests/cordis-catalog-contract.spec.ts @@ -91,7 +91,7 @@ function fixtureRoot(eventsBlock: string): string { const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-')) writeProject( root, - `declare module 'cordis' {\n interface Events {\n${eventsBlock}\n }\n}\n`, + `declare module '@deepseek-ai/cordis' {\n interface Events {\n${eventsBlock}\n }\n}\n`, ) return root } @@ -103,7 +103,7 @@ function serviceFixtureRoot(classSource: string): string { const root = mkdtempSync(join(tmpdir(), 'cordis-catalog-')) writeProject( root, - `declare module 'cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`, + `declare module '@deepseek-ai/cordis' {\n interface Context {\n fix: FixService\n }\n}\n\n${classSource}\n`, ) return root } diff --git a/packages/typert/generator/tests/fixtures/type-model/cordis.d.ts b/packages/typert/generator/tests/fixtures/type-model/cordis.d.ts index 970e8a5dda..fb59bcae21 100644 --- a/packages/typert/generator/tests/fixtures/type-model/cordis.d.ts +++ b/packages/typert/generator/tests/fixtures/type-model/cordis.d.ts @@ -1,4 +1,4 @@ -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { export class Service { protected readonly __service?: never } export interface Context {} diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts b/packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts index 82080a344e..61dc98ba2f 100644 --- a/packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts +++ b/packages/typert/generator/tests/fixtures/type-model/packages/client/src/index.ts @@ -1,4 +1,4 @@ -import { Service } from 'cordis' +import { Service } from '@deepseek-ai/cordis' import type HostDefault from '@fixture/host' import type * as Host from '@fixture/host' import type { AgentPhase } from '@fixture/host' @@ -30,7 +30,7 @@ export class ClientBridge extends Service { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { clientBridge: ClientBridge } diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts b/packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts index bb73873699..463c4dafc7 100644 --- a/packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts +++ b/packages/typert/generator/tests/fixtures/type-model/packages/host/src/index.ts @@ -1,4 +1,4 @@ -import { Service } from 'cordis' +import { Service } from '@deepseek-ai/cordis' import type { ZodType } from 'zod' import type { AgentPhase, Box, Entity, Flags, Payload, Present, SyntaxZoo } from './models.ts' @@ -95,7 +95,7 @@ export class DemoService extends Service { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { demo: DemoService aliased: AliasedService @@ -130,7 +130,7 @@ declare module 'cordis' { type IgnoredDeclaration = string } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { demo: DemoService } diff --git a/packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts b/packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts index 290e3944a3..ea6cc55308 100644 --- a/packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts +++ b/packages/typert/generator/tests/fixtures/type-model/packages/write/src/index.ts @@ -1,4 +1,4 @@ -import { Service } from 'cordis' +import { Service } from '@deepseek-ai/cordis' /** Service whose public annotations are intentionally absent. */ export class WritableService extends Service { @@ -9,7 +9,7 @@ export class WritableService extends Service { } } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { writable: WritableService } diff --git a/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json b/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json index 3885bac238..8c0dc65be8 100644 --- a/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json +++ b/packages/typert/generator/tests/fixtures/type-model/tsconfig.base.json @@ -11,7 +11,7 @@ "ignoreDeprecations": "6.0", "types": ["node"], "paths": { - "cordis": ["./cordis.d.ts"], + "@deepseek-ai/cordis": ["./cordis.d.ts"], "@fixture/host": ["./packages/host/src/index.ts"], "@fixture/host/*": ["./packages/host/src/*"], "@fixture/client": ["./packages/client/src/index.ts"], diff --git a/packages/typert/generator/tests/tools-catalog.spec.ts b/packages/typert/generator/tests/tools-catalog.spec.ts index 95c1ab09de..4bb51e89a9 100644 --- a/packages/typert/generator/tests/tools-catalog.spec.ts +++ b/packages/typert/generator/tests/tools-catalog.spec.ts @@ -2,7 +2,7 @@ import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import type { TypertContribution } from '@deepseek-ai/dsh-typert-registry/types' import { EVENT_API, SERVICE_API, TYPE_API } from '@deepseek-ai/dsh-tool-cordis/src/api-catalog.ts' diff --git a/packages/typert/generator/tests/type-model.spec.ts b/packages/typert/generator/tests/type-model.spec.ts index 81e7f0b981..6911659222 100644 --- a/packages/typert/generator/tests/type-model.spec.ts +++ b/packages/typert/generator/tests/type-model.spec.ts @@ -834,7 +834,7 @@ describe('WorkspaceAnalyzer', { timeout: 60_000 }, () => { writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) writeFileSync(join(hostRoot, 'src/index.ts'), [ 'export {}', - "declare module 'cordis' {", + "declare module '@deepseek-ai/cordis' {", ' interface Context {}', ' interface Events {}', ' interface Ignored {}', @@ -1237,10 +1237,10 @@ function configureDualRuntimeClient(root: string, splitProjects: boolean): void } writeFileSync(manifestPath, `${JSON.stringify(manifest, null, 2)}\n`) writeFileSync(join(packageRoot, 'src/client.ts'), [ - "import { Service } from 'cordis'", + "import { Service } from '@deepseek-ai/cordis'", 'export interface ClientOnlyMarker { readonly client: true }', 'export class BrowserBridge extends Service {}', - "declare module 'cordis' { interface Context { browserBridge: BrowserBridge } }", + "declare module '@deepseek-ai/cordis' { interface Context { browserBridge: BrowserBridge } }", '', ].join('\n')) const indexPath = join(packageRoot, 'src/index.ts') @@ -1338,14 +1338,14 @@ function addExplicitServicePackage(root: string, annotation: string, withProtoco ' /** Report protocol readiness. */', ' ready(): boolean', '}', - "declare module 'cordis' {", + "declare module '@deepseek-ai/cordis' {", ' interface Context { detached: DetachedProtocol }', '}', '', ].join('\n')) } writeFileSync(join(packageRoot, 'src/index.ts'), [ - "import { Service } from 'cordis'", + "import { Service } from '@deepseek-ai/cordis'", ...(withProtocol ? ["export type { DetachedProtocol } from './types.ts'"] : []), '/**', ' * Service implementation discovered independently of its protocol package.', diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index ca6db771e3..ec66e2165b 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/typert/loader" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,19 +32,19 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-typert-registry": "^0.0.1", - "cordis": "^4.0.0-rc.7" - }, - "dependencies": { - "schemastery": "^3.18.0" - }, - "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^" + }, + "dependencies": { + "@deepseek-ai/schemastery": "workspace:^" + }, + "devDependencies": { + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "zod": "^4.4.3" } } diff --git a/packages/typert/loader/src/index.ts b/packages/typert/loader/src/index.ts index 2ae9722dea..7cb73c446f 100644 --- a/packages/typert/loader/src/index.ts +++ b/packages/typert/loader/src/index.ts @@ -29,9 +29,9 @@ import { readFileSync } from 'node:fs' import { createRequire } from 'node:module' import { dirname, join } from 'node:path' import { pathToFileURL } from 'node:url' -import type { Context } from 'cordis' -import z from 'schemastery' -import type {} from '@cordisjs/plugin-loader' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' +import type {} from '@deepseek-ai/cordis-plugin-loader' import type {} from '@deepseek-ai/dsh-typert-registry' import type { TypertContribution } from '@deepseek-ai/dsh-typert-registry/types' diff --git a/packages/typert/loader/src/invariant.ts b/packages/typert/loader/src/invariant.ts index 393324e7e9..dc7070c6cc 100644 --- a/packages/typert/loader/src/invariant.ts +++ b/packages/typert/loader/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-typert-loader' diff --git a/packages/typert/loader/tests/loader.spec.ts b/packages/typert/loader/tests/loader.spec.ts index 491538a533..ac14d39be5 100644 --- a/packages/typert/loader/tests/loader.spec.ts +++ b/packages/typert/loader/tests/loader.spec.ts @@ -4,8 +4,8 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { fileURLToPath, pathToFileURL } from 'node:url' import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import TypertRegistry from '@deepseek-ai/dsh-typert-registry' import * as typertLoader from '@deepseek-ai/dsh-typert-loader' import { validateTypertManifest } from '@deepseek-ai/dsh-typert-loader' diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index f094a0c11c..0a75703929 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/typert/registry" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -50,11 +57,11 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/typert/registry/src/client/index.ts b/packages/typert/registry/src/client/index.ts index e468e78999..1eb4b1246d 100644 --- a/packages/typert/registry/src/client/index.ts +++ b/packages/typert/registry/src/client/index.ts @@ -1,6 +1,6 @@ /** Browser face of the shared TypeRT runtime registry. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { TypertRegistry } from '../service.ts' /** Required services: none; this is the Client reflection root. */ diff --git a/packages/typert/registry/src/invariant.ts b/packages/typert/registry/src/invariant.ts index 73b01a6742..93c63f786c 100644 --- a/packages/typert/registry/src/invariant.ts +++ b/packages/typert/registry/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-typert-registry' diff --git a/packages/typert/registry/src/service.ts b/packages/typert/registry/src/service.ts index 353ad4c9e0..b8bab3a121 100644 --- a/packages/typert/registry/src/service.ts +++ b/packages/typert/registry/src/service.ts @@ -5,7 +5,7 @@ * @module @deepseek-ai/dsh-typert-registry */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { z } from 'zod' import type { InvocationDescriptor, diff --git a/packages/typert/registry/tests/typert.spec.ts b/packages/typert/registry/tests/typert.spec.ts index 92b81803c7..1bc4b65cb1 100644 --- a/packages/typert/registry/tests/typert.spec.ts +++ b/packages/typert/registry/tests/typert.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { z } from 'zod' import TypertRegistry, { typertEndpoint, diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json index e2d7689866..3195392acb 100644 --- a/packages/typert/type-meta/package.json +++ b/packages/typert/type-meta/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-type-meta", "description": "Compiler-independent Remote metadata and TypeRT provider protocols", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/typert/type-meta" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -30,11 +37,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/typert/type-meta/src/index.ts b/packages/typert/type-meta/src/index.ts index 1375d7872b..40a8b5b2ad 100644 --- a/packages/typert/type-meta/src/index.ts +++ b/packages/typert/type-meta/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-type-meta */ -import { Service, type Context } from 'cordis' +import { Service, type Context } from '@deepseek-ai/cordis' import type { TypeRTContextMap } from './types.ts' const TYPERT_REMOTE_SEGMENT_PATTERN = /^[A-Za-z0-9_$.-]+$/ diff --git a/packages/typert/type-meta/src/invariant.ts b/packages/typert/type-meta/src/invariant.ts index 22dc290a1e..304f0f9ac4 100644 --- a/packages/typert/type-meta/src/invariant.ts +++ b/packages/typert/type-meta/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-type-meta' diff --git a/packages/typert/type-meta/src/types.ts b/packages/typert/type-meta/src/types.ts index c1d6b3dcf9..622698bf6c 100644 --- a/packages/typert/type-meta/src/types.ts +++ b/packages/typert/type-meta/src/types.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-type-meta/types */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' declare const LOOKUP_HOST: unique symbol declare const LOOKUP_WIRE: unique symbol @@ -421,7 +421,7 @@ export interface TypeRTService { readonly contexts: TypeRTContextRegistry } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { typert: TypeRTService } diff --git a/packages/typert/type-meta/tests/fixtures/source-launch.ts b/packages/typert/type-meta/tests/fixtures/source-launch.ts index 14eec6610d..55bea062ae 100644 --- a/packages/typert/type-meta/tests/fixtures/source-launch.ts +++ b/packages/typert/type-meta/tests/fixtures/source-launch.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { GatewayService, Remote, diff --git a/packages/typert/type-meta/tests/type-meta.spec.ts b/packages/typert/type-meta/tests/type-meta.spec.ts index bfe99630b9..aa77541128 100644 --- a/packages/typert/type-meta/tests/type-meta.spec.ts +++ b/packages/typert/type-meta/tests/type-meta.spec.ts @@ -1,6 +1,6 @@ import { execFileSync } from 'node:child_process' import { fileURLToPath } from 'node:url' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import { bindTypeRTGateway, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 00de333170..a26a5e62d3 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-atomic-write", "description": "Zero-dependency atomic file replacement: exclusive-create random-suffix temp + rename carrying the caller-stated permissions (writeFileAtomic)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/atomic-write" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/atomic-write/src/invariant.ts b/packages/util/atomic-write/src/invariant.ts index 4027dd9bda..241d439cdf 100644 --- a/packages/util/atomic-write/src/invariant.ts +++ b/packages/util/atomic-write/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-atomic-write' diff --git a/packages/util/atomic-write/tests/invariant.spec.ts b/packages/util/atomic-write/tests/invariant.spec.ts index c80346762c..ab77662f47 100644 --- a/packages/util/atomic-write/tests/invariant.spec.ts +++ b/packages/util/atomic-write/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import * as AtomicWriteInvariant from '../src/invariant.ts' diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 83676d1d5c..1496dfdb3a 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Type-only Branded nominal-typing primitive for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/brand" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/brand/src/invariant.ts b/packages/util/brand/src/invariant.ts index bf29a81b4c..cd33bf10cb 100644 --- a/packages/util/brand/src/invariant.ts +++ b/packages/util/brand/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-brand' diff --git a/packages/util/environment/README.i18n.yaml b/packages/util/environment/README.i18n.yaml index c8e36d8a5e..17b9c8d879 100644 --- a/packages/util/environment/README.i18n.yaml +++ b/packages/util/environment/README.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write packages/util/environment/README.md -README.md: 599a9ba747905b66452b57717dabcc6f4678a3dd -README.zh.md: d728f835b1ece44d2848a618e504951f26fec7e2 +README.md: cdbc5b6dd4a5ea90c323f570598e2db124b43857 +README.zh.md: bbef1719370c787763eaa686a5609ec31833386e diff --git a/packages/util/environment/README.md b/packages/util/environment/README.md index 599a9ba747..cdbc5b6dd4 100644 --- a/packages/util/environment/README.md +++ b/packages/util/environment/README.md @@ -21,7 +21,7 @@ Values do also reach `process.env` — a user's `--config` tree and third-party Names match the way the platform matches them: exactly on POSIX, case-insensitively on Windows. A case-sensitive lookup there would rank the wrong layer — a shell's `deepseek_api_key` and a project `.env`'s `DEEPSEEK_API_KEY` are one variable to the OS, and treating them as two would let the project win. ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' declare const ctx: Context diff --git a/packages/util/environment/README.zh.md b/packages/util/environment/README.zh.md index d728f835b1..bbef171937 100644 --- a/packages/util/environment/README.zh.md +++ b/packages/util/environment/README.zh.md @@ -21,7 +21,7 @@ 变量名按平台自身的规则匹配:POSIX 上精确匹配,Windows 上不区分大小写。在 Windows 上做大小写敏感的查找会选错层——shell 里的 `deepseek_api_key` 与项目 `.env` 里的 `DEEPSEEK_API_KEY` 对操作系统而言是同一个变量,把它们当成两个就会让项目胜出。 ```ts -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' declare const ctx: Context diff --git a/packages/util/environment/package.json b/packages/util/environment/package.json index 15cbb603a5..6fa44ab20c 100644 --- a/packages/util/environment/package.json +++ b/packages/util/environment/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/environment" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/environment/src/index.ts b/packages/util/environment/src/index.ts index 63a59c7337..d40cbc3c3b 100644 --- a/packages/util/environment/src/index.ts +++ b/packages/util/environment/src/index.ts @@ -6,7 +6,7 @@ * @module @deepseek-ai/dsh-environment */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' /** * Which layer supplied a value, from most to least trusted: the environment @@ -116,7 +116,7 @@ export function environmentOf(ctx: Context): EnvironmentSnapshot { ?? createEnvironmentSnapshot([{ source: 'process', values: process.env as Record }]) } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { /** Launcher-owned snapshot of this run's environment; absent in compositions the product CLI did not boot. */ launcherEnvironment?: EnvironmentSnapshot diff --git a/packages/util/environment/src/invariant.ts b/packages/util/environment/src/invariant.ts index 96e53828ae..f5ca3b698c 100644 --- a/packages/util/environment/src/invariant.ts +++ b/packages/util/environment/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-environment' diff --git a/packages/util/environment/tests/environment.spec.ts b/packages/util/environment/tests/environment.spec.ts index 5951484a83..4ae4e93d8a 100644 --- a/packages/util/environment/tests/environment.spec.ts +++ b/packages/util/environment/tests/environment.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { createEnvironmentSnapshot, DSH_ENVIRONMENT_KEY, environmentOf, } from '../src/index.ts' diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index d282a128f7..af0891fb82 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-native-command", "description": "Zero-dependency no-shell execFile runner for host-native OS integrations: utf8 stdio capture, abort propagation, Windows hide", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/native-command" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/native-command/src/invariant.ts b/packages/util/native-command/src/invariant.ts index bec1d4b774..0504f06e3e 100644 --- a/packages/util/native-command/src/invariant.ts +++ b/packages/util/native-command/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-native-command' diff --git a/packages/util/paths/package.json b/packages/util/paths/package.json index cece1ce79e..e3c492ea34 100644 --- a/packages/util/paths/package.json +++ b/packages/util/paths/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/paths" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/paths/src/invariant.ts b/packages/util/paths/src/invariant.ts index f1661b7f52..92a636e57b 100644 --- a/packages/util/paths/src/invariant.ts +++ b/packages/util/paths/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-paths' diff --git a/packages/util/retention/package.json b/packages/util/retention/package.json index 80af828263..eea04fee3e 100644 --- a/packages/util/retention/package.json +++ b/packages/util/retention/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-retention", "description": "Zero-dependency bounded-retention primitive: ItemRetainer/TextRetainer + neutral notice helpers (what did we keep, what did we omit)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/retention" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/retention/src/invariant.ts b/packages/util/retention/src/invariant.ts index 0365793b03..90f7cf5252 100644 --- a/packages/util/retention/src/invariant.ts +++ b/packages/util/retention/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-retention' diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 853cee4d79..72daaa2c6b 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/util/timeout" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/timeout/src/invariant.ts b/packages/util/timeout/src/invariant.ts index bb9604d6b7..1284ecf8da 100644 --- a/packages/util/timeout/src/invariant.ts +++ b/packages/util/timeout/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-timeout' diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index d5d655b2dd..0135e83403 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-web", "description": "Model-facing web tools (web_search, web_fetch) over the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/web/tool-web" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@joplin/turndown-plugin-gfm": "^1.0.67", - "schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "turndown": "^7.2.4" }, "devDependencies": { @@ -51,6 +58,6 @@ "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-local": "workspace:^", "@deepseek-ai/dsh-web-search-exa": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/web/tool-web/src/fetch.ts b/packages/web/tool-web/src/fetch.ts index 94130df697..b28af9fa0a 100644 --- a/packages/web/tool-web/src/fetch.ts +++ b/packages/web/tool-web/src/fetch.ts @@ -5,7 +5,7 @@ * signal. A provider timeout remains a backstop for direct service callers. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import TurndownService from 'turndown' import { gfm } from '@joplin/turndown-plugin-gfm' import { defineTool } from '@deepseek-ai/dsh-tools' diff --git a/packages/web/tool-web/src/index.ts b/packages/web/tool-web/src/index.ts index aad4d8728c..b9bd0ddcc8 100644 --- a/packages/web/tool-web/src/index.ts +++ b/packages/web/tool-web/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-tool-web */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-web' import { applyWebSearchTool, WEB_SEARCH_MAX_RESULTS } from './search.ts' import { applyWebFetchTool } from './fetch.ts' diff --git a/packages/web/tool-web/src/invariant.ts b/packages/web/tool-web/src/invariant.ts index 435f9ca549..a9ea21c3a9 100644 --- a/packages/web/tool-web/src/invariant.ts +++ b/packages/web/tool-web/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-web' diff --git a/packages/web/tool-web/src/search.ts b/packages/web/tool-web/src/search.ts index 35c97dfda2..e550d10096 100644 --- a/packages/web/tool-web/src/search.ts +++ b/packages/web/tool-web/src/search.ts @@ -5,7 +5,7 @@ * never provider selection or network access. */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { defineTool } from '@deepseek-ai/dsh-tools' import type { GenericCallView, JsonValue, ToolResult, WebSearchResultView, WebSource } from '@deepseek-ai/dsh-tools' import type { WebSearchResult, WebSearchSource } from '@deepseek-ai/dsh-web' diff --git a/packages/web/tool-web/tests/integration.spec.ts b/packages/web/tool-web/tests/integration.spec.ts index e235d4d455..8c64ced81a 100644 --- a/packages/web/tool-web/tests/integration.spec.ts +++ b/packages/web/tool-web/tests/integration.spec.ts @@ -9,7 +9,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { AddressInfo } from 'node:net' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { type ToolExecutionResult } from '@deepseek-ai/dsh-tools' diff --git a/packages/web/tool-web/tests/load-path.spec.ts b/packages/web/tool-web/tests/load-path.spec.ts index 1df718b871..32b1fe06fc 100644 --- a/packages/web/tool-web/tests/load-path.spec.ts +++ b/packages/web/tool-web/tests/load-path.spec.ts @@ -6,8 +6,8 @@ */ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry from '@deepseek-ai/dsh-tools' import WebService from '@deepseek-ai/dsh-web' diff --git a/packages/web/tool-web/tests/spill.spec.ts b/packages/web/tool-web/tests/spill.spec.ts index 33279fc9e9..1f972360b8 100644 --- a/packages/web/tool-web/tests/spill.spec.ts +++ b/packages/web/tool-web/tests/spill.spec.ts @@ -13,7 +13,7 @@ import { AddressInfo } from 'node:net' import { mkdtempSync, readFileSync, rmSync } from 'node:fs' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/web/tool-web/tests/tool-web.spec.ts b/packages/web/tool-web/tests/tool-web.spec.ts index de9bf79284..781209a9cc 100644 --- a/packages/web/tool-web/tests/tool-web.spec.ts +++ b/packages/web/tool-web/tests/tool-web.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import TurndownService from 'turndown' import { CallId } from '@deepseek-ai/dsh-llm' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json index e6dc5791d9..562f8be8f6 100644 --- a/packages/web/web-fetch-local/package.json +++ b/packages/web/web-fetch-local/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-web-fetch-local", "description": "Anonymous public HTTP(S) fetch provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/web/web-fetch-local" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,18 +32,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-timeout": "^0.0.1", - "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-timeout": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/web/web-fetch-local/src/index.ts b/packages/web/web-fetch-local/src/index.ts index a5636b37ee..92fe6c3025 100644 --- a/packages/web/web-fetch-local/src/index.ts +++ b/packages/web/web-fetch-local/src/index.ts @@ -7,8 +7,8 @@ * @module @deepseek-ai/dsh-web-fetch-local */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-web' import { LocalFetchProvider } from './provider.ts' import type { LocalFetchLimits } from './provider.ts' diff --git a/packages/web/web-fetch-local/src/invariant.ts b/packages/web/web-fetch-local/src/invariant.ts index 053fb12200..13f1990549 100644 --- a/packages/web/web-fetch-local/src/invariant.ts +++ b/packages/web/web-fetch-local/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-fetch-local' diff --git a/packages/web/web-fetch-local/tests/fetch-local.spec.ts b/packages/web/web-fetch-local/tests/fetch-local.spec.ts index 092e384d94..8200d0fe1d 100644 --- a/packages/web/web-fetch-local/tests/fetch-local.spec.ts +++ b/packages/web/web-fetch-local/tests/fetch-local.spec.ts @@ -1,7 +1,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' import { createServer, type IncomingMessage, type Server, type ServerResponse } from 'node:http' import { AddressInfo } from 'node:net' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import WebService from '@deepseek-ai/dsh-web' import { LocalFetchProvider, LOCAL_FETCH_PROVIDER_ID } from '@deepseek-ai/dsh-web-fetch-local' import type { LocalFetchLimits } from '@deepseek-ai/dsh-web-fetch-local' diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index 0ca52390de..c3d304678c 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-web-search-deepseek", "description": "DeepSeek-backed search provider (native web_search via the Anthropic-compatible API) for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/web/web-search-deepseek" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-credentials": "^0.0.1", - "@deepseek-ai/dsh-environment": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-credentials": "workspace:^", + "@deepseek-ai/dsh-environment": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -44,6 +51,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/web/web-search-deepseek/src/index.ts b/packages/web/web-search-deepseek/src/index.ts index 5e55e12457..8b465d9617 100644 --- a/packages/web/web-search-deepseek/src/index.ts +++ b/packages/web/web-search-deepseek/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-web-search-deepseek */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-agent' import { credentialRef } from '@deepseek-ai/dsh-credentials' import { environmentOf } from '@deepseek-ai/dsh-environment' diff --git a/packages/web/web-search-deepseek/src/invariant.ts b/packages/web/web-search-deepseek/src/invariant.ts index d1f707f2bc..1f0b59d9e0 100644 --- a/packages/web/web-search-deepseek/src/invariant.ts +++ b/packages/web/web-search-deepseek/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-deepseek' diff --git a/packages/web/web-search-deepseek/tests/deepseek.spec.ts b/packages/web/web-search-deepseek/tests/deepseek.spec.ts index 23c2d2c237..af51213660 100644 --- a/packages/web/web-search-deepseek/tests/deepseek.spec.ts +++ b/packages/web/web-search-deepseek/tests/deepseek.spec.ts @@ -2,8 +2,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdtemp, rm } from 'node:fs/promises' import { tmpdir } from 'node:os' import { join } from 'node:path' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import { credentialRef } from '@deepseek-ai/dsh-credentials' import CredentialsLocal from '@deepseek-ai/dsh-credentials-local' import WebService from '@deepseek-ai/dsh-web' diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index c2d55e00ff..55b18bec50 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-web-search-exa", "description": "Exa-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/web/web-search-exa" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,18 +32,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-environment": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-environment": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/web/web-search-exa/src/index.ts b/packages/web/web-search-exa/src/index.ts index 2ecb71336a..0b37cd5c1a 100644 --- a/packages/web/web-search-exa/src/index.ts +++ b/packages/web/web-search-exa/src/index.ts @@ -8,9 +8,9 @@ * @module @deepseek-ai/dsh-web-search-exa */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-web' import { ExaSearchProvider, diff --git a/packages/web/web-search-exa/src/invariant.ts b/packages/web/web-search-exa/src/invariant.ts index 060ceb78ba..d7ba293ef1 100644 --- a/packages/web/web-search-exa/src/invariant.ts +++ b/packages/web/web-search-exa/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-exa' diff --git a/packages/web/web-search-exa/tests/exa.spec.ts b/packages/web/web-search-exa/tests/exa.spec.ts index 6e29b10aa8..86c20a3ffb 100644 --- a/packages/web/web-search-exa/tests/exa.spec.ts +++ b/packages/web/web-search-exa/tests/exa.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import WebService from '@deepseek-ai/dsh-web' import { ExaSearchProvider, EXA_PROVIDER_ID } from '@deepseek-ai/dsh-web-search-exa' import * as exaPlugin from '@deepseek-ai/dsh-web-search-exa' diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 1ece514da9..09c3b823ae 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-web-search-perplexity", "description": "Perplexity-backed search provider for the DeepSeek Harness web capability seam (ctx.web)", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/web/web-search-perplexity" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,18 +32,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-environment": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-web": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-environment": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-web": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/web/web-search-perplexity/src/index.ts b/packages/web/web-search-perplexity/src/index.ts index e1fe6a2606..e3ac97605d 100644 --- a/packages/web/web-search-perplexity/src/index.ts +++ b/packages/web/web-search-perplexity/src/index.ts @@ -7,9 +7,9 @@ * @module @deepseek-ai/dsh-web-search-perplexity */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { environmentOf } from '@deepseek-ai/dsh-environment' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' import type {} from '@deepseek-ai/dsh-web' import { PerplexitySearchProvider, PERPLEXITY_DEFAULT_BASE_URL, PERPLEXITY_DEFAULT_MAX_TOKENS, PERPLEXITY_DEFAULT_MODEL } from './provider.ts' diff --git a/packages/web/web-search-perplexity/src/invariant.ts b/packages/web/web-search-perplexity/src/invariant.ts index cf3e009fed..fa4d1981b6 100644 --- a/packages/web/web-search-perplexity/src/invariant.ts +++ b/packages/web/web-search-perplexity/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web-search-perplexity' diff --git a/packages/web/web-search-perplexity/tests/perplexity.spec.ts b/packages/web/web-search-perplexity/tests/perplexity.spec.ts index b622342384..d12c3957ca 100644 --- a/packages/web/web-search-perplexity/tests/perplexity.spec.ts +++ b/packages/web/web-search-perplexity/tests/perplexity.spec.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import WebService from '@deepseek-ai/dsh-web' import { PerplexitySearchProvider, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 4ea964ee93..21e9811c1e 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-web", "description": "Abstract web access capability seam (ctx.web) for the DeepSeek Harness — search/fetch provider registry, registration-order-independent selection, request/result vocabulary, and the WebError taxonomy", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/web/web" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/web/web/src/index.ts b/packages/web/web/src/index.ts index ded152cc81..eeadfaed77 100644 --- a/packages/web/web/src/index.ts +++ b/packages/web/web/src/index.ts @@ -6,8 +6,8 @@ * @module @deepseek-ai/dsh-web */ -import { Context, Service } from 'cordis' -import z from 'schemastery' +import { Context, Service } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { WebFetchProvider, WebFetchRequest, @@ -32,7 +32,7 @@ export type { WebSearchSource, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { web: WebService } diff --git a/packages/web/web/src/invariant.ts b/packages/web/web/src/invariant.ts index 2ac094b34f..4ec2462d13 100644 --- a/packages/web/web/src/invariant.ts +++ b/packages/web/web/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-web' diff --git a/packages/web/web/tests/web.spec.ts b/packages/web/web/tests/web.spec.ts index 978284ee51..7fcb3b1f28 100644 --- a/packages/web/web/tests/web.spec.ts +++ b/packages/web/web/tests/web.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import WebService, { WebError, type WebFetchProvider, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 83bf0d057c..f92eb944c4 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/workflow/tool-ralph" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,20 +32,20 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-workflow": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@cordisjs/plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", "@deepseek-ai/dsh-agent-loop-testkit": "workspace:^", @@ -52,6 +59,6 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/workflow/tool-ralph/src/index.ts b/packages/workflow/tool-ralph/src/index.ts index e6e7edfccf..86e7d28843 100644 --- a/packages/workflow/tool-ralph/src/index.ts +++ b/packages/workflow/tool-ralph/src/index.ts @@ -5,8 +5,8 @@ * @module @deepseek-ai/dsh-tool-ralph */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { JsonValue } from '@deepseek-ai/dsh-session' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' diff --git a/packages/workflow/tool-ralph/src/invariant.ts b/packages/workflow/tool-ralph/src/invariant.ts index 22a7d1f2ea..3b050136fb 100644 --- a/packages/workflow/tool-ralph/src/invariant.ts +++ b/packages/workflow/tool-ralph/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-ralph' diff --git a/packages/workflow/tool-ralph/tests/integration.spec.ts b/packages/workflow/tool-ralph/tests/integration.spec.ts index eb379dde16..d05d011a41 100644 --- a/packages/workflow/tool-ralph/tests/integration.spec.ts +++ b/packages/workflow/tool-ralph/tests/integration.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts index 29ba7edd4e..7033b89bd8 100644 --- a/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts +++ b/packages/workflow/tool-ralph/tests/tool-ralph.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import { CallId } from '@deepseek-ai/dsh-llm' import { SessionId } from '@deepseek-ai/dsh-session' diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 705e4f6e08..4d8bc7b924 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflows", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/workflow/tool-workflow" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-workflow": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -46,6 +53,6 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/workflow/tool-workflow/src/index.ts b/packages/workflow/tool-workflow/src/index.ts index 6c1e9b19bb..af9aeed5e8 100644 --- a/packages/workflow/tool-workflow/src/index.ts +++ b/packages/workflow/tool-workflow/src/index.ts @@ -10,8 +10,8 @@ * @module @deepseek-ai/dsh-tool-workflow */ -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import { defineTool } from '@deepseek-ai/dsh-tools' import type { ToolCallView, ToolResultView } from '@deepseek-ai/dsh-tools' import type { ContentBlock } from '@deepseek-ai/dsh-llm' diff --git a/packages/workflow/tool-workflow/src/invariant.ts b/packages/workflow/tool-workflow/src/invariant.ts index 5f3ebc68ce..74edbb21dc 100644 --- a/packages/workflow/tool-workflow/src/invariant.ts +++ b/packages/workflow/tool-workflow/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-tool-workflow' diff --git a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts index a61862cffd..687fd675c7 100644 --- a/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts +++ b/packages/workflow/tool-workflow/tests/tool-workflow.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' import ToolRegistry, { TOOL_ABORTED_BEFORE_DISPATCH } from '@deepseek-ai/dsh-tools' import type { ToolExecutionResult } from '@deepseek-ai/dsh-tools' diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index e019bcaa51..af20d74fb2 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-workflow-workerthread", "description": "worker-thread workflow engine: executes model-written orchestration scripts off the host event loop, bridging agent() calls back to ctx.subagents", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/workflow/workflow-workerthread" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -30,18 +37,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-subagent": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/dsh-workflow": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-subagent": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/dsh-workflow": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -56,7 +63,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "tsx": "^4.19.2" } } diff --git a/packages/workflow/workflow-workerthread/src/host.ts b/packages/workflow/workflow-workerthread/src/host.ts index 57570a5098..501a3555c3 100644 --- a/packages/workflow/workflow-workerthread/src/host.ts +++ b/packages/workflow/workflow-workerthread/src/host.ts @@ -9,7 +9,7 @@ import { Worker } from 'node:worker_threads' import type { WorkerOptions } from 'node:worker_threads' import { fileURLToPath } from 'node:url' -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import { assertNever } from '@deepseek-ai/dsh-llm' import { snapshotJsonValue } from '@deepseek-ai/dsh-session' diff --git a/packages/workflow/workflow-workerthread/src/index.ts b/packages/workflow/workflow-workerthread/src/index.ts index 33c5917acf..309da3af97 100644 --- a/packages/workflow/workflow-workerthread/src/index.ts +++ b/packages/workflow/workflow-workerthread/src/index.ts @@ -9,8 +9,8 @@ import { randomUUID } from 'node:crypto' import { availableParallelism } from 'node:os' import * as vm from 'node:vm' -import type { Context } from 'cordis' -import z from 'schemastery' +import type { Context } from '@deepseek-ai/cordis' +import z from '@deepseek-ai/schemastery' import WorkflowService, { WorkflowError, WorkflowRunId } from '@deepseek-ai/dsh-workflow' import type { WorkflowRun, WorkflowRunInfo, WorkflowStartRequest } from '@deepseek-ai/dsh-workflow' import { WorkerRun } from './host.ts' diff --git a/packages/workflow/workflow-workerthread/src/invariant.ts b/packages/workflow/workflow-workerthread/src/invariant.ts index 6845aeebff..401292691a 100644 --- a/packages/workflow/workflow-workerthread/src/invariant.ts +++ b/packages/workflow/workflow-workerthread/src/invariant.ts @@ -4,7 +4,7 @@ */ /* jscpd:ignore-start */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' const PACKAGE_NAME = '@deepseek-ai/dsh-workflow-workerthread' diff --git a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts index e330b3ee83..7b94e48b96 100644 --- a/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/built-worker.e2e.ts @@ -21,7 +21,7 @@ describe.skipIf(!existsSync(builtIndex) || !existsSync(builtWorker))('built work const driver = join(packageRoot, `.built-worker-driver-${process.pid}.mjs`) try { await writeFile(driver, ` -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import SubagentService from '@deepseek-ai/dsh-subagent' import WorkerWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' diff --git a/packages/workflow/workflow-workerthread/tests/integration.spec.ts b/packages/workflow/workflow-workerthread/tests/integration.spec.ts index e137d7f94f..fe71dcf750 100644 --- a/packages/workflow/workflow-workerthread/tests/integration.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/integration.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' import AgentLoop from '@deepseek-ai/dsh-agent-loop' import { mountAgentLoopTestDependencies } from '@deepseek-ai/dsh-agent-loop-testkit' diff --git a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts index 3fb08bb5ba..7eecf1a52b 100644 --- a/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/source-worker.compat.spec.ts @@ -5,7 +5,7 @@ */ import { expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentProvider } from '@deepseek-ai/dsh-subagent' diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts index 08e1845d22..5c6128cb38 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.e2e.ts @@ -1,5 +1,5 @@ import { afterEach, describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import LlmService from '@deepseek-ai/dsh-llm' import SessionStore, { SessionId } from '@deepseek-ai/dsh-session' import SystemPrompt from '@deepseek-ai/dsh-system-prompt' diff --git a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts index de190349a4..8b580d8ce5 100644 --- a/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts +++ b/packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts @@ -1,8 +1,8 @@ import { describe, expect, it, vi } from 'vitest' import { fileURLToPath } from 'node:url' import type { Worker } from 'node:worker_threads' -import { Context } from 'cordis' -import Loader from '@cordisjs/plugin-loader' +import { Context } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' import type { Agent } from '@deepseek-ai/dsh-agent' import SubagentService from '@deepseek-ai/dsh-subagent' import type { SubagentCapabilities, SubagentProvider, SubagentResult, SubagentRun, SubagentStartRequest } from '@deepseek-ai/dsh-subagent' diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 53ef7e6f6e..23976826bb 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflows service, run vocabulary, and workflow/* events", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/workflow/workflow" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -25,12 +32,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-agent": "^0.0.1", - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-agent": "workspace:^", + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -38,6 +45,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/workflow/workflow/src/index.ts b/packages/workflow/workflow/src/index.ts index 526da15ad8..380729d148 100644 --- a/packages/workflow/workflow/src/index.ts +++ b/packages/workflow/workflow/src/index.ts @@ -4,7 +4,7 @@ * @module @deepseek-ai/dsh-workflow */ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { HarnessError } from '@deepseek-ai/dsh-llm' import type { WorkflowAgentEndInfo, @@ -30,7 +30,7 @@ export type { WorkflowStopReason, } from './types.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { workflows: WorkflowService } diff --git a/packages/workflow/workflow/src/invariant.ts b/packages/workflow/workflow/src/invariant.ts index f6b8b8ced2..47dc82d0bf 100644 --- a/packages/workflow/workflow/src/invariant.ts +++ b/packages/workflow/workflow/src/invariant.ts @@ -1,6 +1,6 @@ /** Package-owned workflow lifecycle invariants. @module @deepseek-ai/dsh-workflow/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantFailure, InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { WorkflowAgentEndInfo, diff --git a/packages/workflow/workflow/tests/invariant.spec.ts b/packages/workflow/workflow/tests/invariant.spec.ts index 671a7a3e86..54eed758fa 100644 --- a/packages/workflow/workflow/tests/invariant.spec.ts +++ b/packages/workflow/workflow/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { SessionId } from '@deepseek-ai/dsh-session' import { WorkflowRunId } from '@deepseek-ai/dsh-workflow' import type { diff --git a/packages/workflow/workflow/tests/workflow.spec.ts b/packages/workflow/workflow/tests/workflow.spec.ts index 1cdf15c26d..689d959c9c 100644 --- a/packages/workflow/workflow/tests/workflow.spec.ts +++ b/packages/workflow/workflow/tests/workflow.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it, vi } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import WorkflowServiceDefault, { isFatalWorkflowError, WorkflowError, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 7839b29702..4ab1aeecbe 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,8 +1,15 @@ { "name": "@deepseek-ai/dsh-workspace", "description": "Workspace entity registry (ctx.workspace): durable workspace records with validated session attachment over the domain data form for the DeepSeek Harness", - "version": "0.0.1", - "private": true, + "version": "0.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "packages/workspace/workspace" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -30,13 +37,13 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-storage-domain": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/dsh-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-storage": "^0.0.1", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-brand": "workspace:^", + "@deepseek-ai/dsh-storage-domain": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-session-persistence": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "zod": "^4.4.3" @@ -48,6 +55,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/workspace/workspace/src/index.ts b/packages/workspace/workspace/src/index.ts index d2d20d65ab..d972085939 100644 --- a/packages/workspace/workspace/src/index.ts +++ b/packages/workspace/workspace/src/index.ts @@ -8,7 +8,7 @@ import { randomUUID } from 'node:crypto' import { stat } from 'node:fs/promises' import { basename } from 'node:path' -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import type { SessionHeader, SessionId } from '@deepseek-ai/dsh-session' import type {} from '@deepseek-ai/dsh-session-persistence' import type { DomainGlobal, KvTable } from '@deepseek-ai/dsh-storage-domain' @@ -53,7 +53,7 @@ export class WorkspaceUnknownSessionError extends Error { } -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { workspace: WorkspaceRegistry } diff --git a/packages/workspace/workspace/src/invariant.ts b/packages/workspace/workspace/src/invariant.ts index 808ce1dedf..abcc61bca1 100644 --- a/packages/workspace/workspace/src/invariant.ts +++ b/packages/workspace/workspace/src/invariant.ts @@ -3,7 +3,7 @@ * @module @deepseek-ai/dsh-workspace/invariant */ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import { WorkspaceId } from '@deepseek-ai/dsh-workspace' diff --git a/packages/workspace/workspace/tests/invariant.spec.ts b/packages/workspace/workspace/tests/invariant.spec.ts index 0d0556a44a..803a7dfe4b 100644 --- a/packages/workspace/workspace/tests/invariant.spec.ts +++ b/packages/workspace/workspace/tests/invariant.spec.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import InvariantService from '@deepseek-ai/dsh-invariants' import type { DomainChanged } from '@deepseek-ai/dsh-storage-domain' import * as WorkspaceInvariant from '../src/invariant.ts' diff --git a/packages/workspace/workspace/tests/workspace.spec.ts b/packages/workspace/workspace/tests/workspace.spec.ts index 6a562e15bd..3c4b6185fb 100644 --- a/packages/workspace/workspace/tests/workspace.spec.ts +++ b/packages/workspace/workspace/tests/workspace.spec.ts @@ -2,7 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { mkdir, mkdtemp, realpath, rm, symlink, writeFile } from 'node:fs/promises' import { tmpdir } from 'node:os' import { basename, join } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import Storage from '@deepseek-ai/dsh-storage' import type { StorageBackend } from '@deepseek-ai/dsh-storage' import { DomainFacility } from '@deepseek-ai/dsh-storage-domain' diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index f36be6b1b6..5612f5bc13 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4,6 +4,10 @@ settings: autoInstallPeers: true excludeLinksFromLockfile: false +overrides: + '@deepseek-ai/cosmokit': link:vendor/cosmokit + '@deepseek-ai/schemastery': link:vendor/schemastery + patchedDependencies: node-pty@1.1.0: 7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6 @@ -122,17 +126,20 @@ importers: apps/cli: dependencies: - '@cordisjs/plugin-hmr': - specifier: workspace:* + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../vendor/cordis + '@deepseek-ai/cordis-plugin-hmr': + specifier: workspace:^ version: link:../../vendor/hmr - '@cordisjs/plugin-include': - specifier: workspace:* + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ version: link:../../vendor/include - '@cordisjs/plugin-loader': - specifier: workspace:* + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ version: link:../../vendor/loader - '@cordisjs/plugin-timer': - specifier: workspace:* + '@deepseek-ai/cordis-plugin-timer': + specifier: workspace:^ version: link:../../vendor/timer '@deepseek-ai/dsh-agent-tool-mode': specifier: workspace:^ @@ -146,6 +153,9 @@ importers: '@deepseek-ai/dsh-client-ui-agent-preset': specifier: workspace:^ version: link:../../packages/client/ui-agent-preset + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline '@deepseek-ai/dsh-command-compact': specifier: workspace:^ version: link:../../packages/compact/command-compact @@ -158,6 +168,9 @@ importers: '@deepseek-ai/dsh-compact-tool-result-prune': specifier: workspace:^ version: link:../../packages/compact/compact-tool-result-prune + '@deepseek-ai/dsh-environment': + specifier: workspace:^ + version: link:../../packages/util/environment '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal @@ -191,6 +204,9 @@ importers: '@deepseek-ai/dsh-pwsh-sandbox': specifier: workspace:^ version: link:../../packages/bash/pwsh-sandbox + '@deepseek-ai/dsh-session-projection': + specifier: workspace:^ + version: link:../../packages/session/session-projection '@deepseek-ai/dsh-session-reference': specifier: workspace:^ version: link:../../packages/context/session-reference @@ -272,9 +288,6 @@ importers: commander: specifier: ^15.0.0 version: 15.0.0 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../vendor/cordis js-yaml: specifier: ^4.2.0 version: 4.2.0 @@ -337,7 +350,7 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: - '@cordisjs/plugin-group': + '@deepseek-ai/cordis-plugin-group': specifier: workspace:^ version: link:../../vendor/group '@deepseek-ai/dsh-client-modules': @@ -352,6 +365,9 @@ importers: '@deepseek-ai/dsh-client-web-react': specifier: workspace:^ version: link:../../packages/client/web-react + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../packages/boot/cmdline '@deepseek-ai/dsh-pwsh-local': specifier: workspace:^ version: link:../../packages/bash/pwsh-local @@ -367,6 +383,9 @@ importers: '@vitejs/plugin-react': specifier: ^4.0.0 version: 4.7.0(vite@6.4.3(@types/node@22.20.0)(jiti@2.7.0)(lightningcss@1.32.0)(tsx@4.22.4)(yaml@2.9.0)) + fflate: + specifier: ^0.8.2 + version: 0.8.3 playwright: specifier: ^1.49.0 version: 1.61.1 @@ -382,16 +401,16 @@ importers: examples: dependencies: - '@cordisjs/plugin-hmr': + '@deepseek-ai/cordis-plugin-hmr': specifier: workspace:* version: link:../vendor/hmr - '@cordisjs/plugin-include': + '@deepseek-ai/cordis-plugin-include': specifier: workspace:* version: link:../vendor/include - '@cordisjs/plugin-logger-console': + '@deepseek-ai/cordis-plugin-logger-console': specifier: workspace:* version: link:../vendor/logger-console - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis-plugin-timer': specifier: workspace:* version: link:../vendor/timer '@deepseek-ai/dsh-acp-demo': @@ -409,6 +428,9 @@ importers: '@deepseek-ai/dsh-app-boot': specifier: workspace:* version: link:../packages/boot/app-boot + '@deepseek-ai/dsh-attachment-local': + specifier: workspace:* + version: link:../packages/attachment/attachment-local '@deepseek-ai/dsh-bash': specifier: workspace:* version: link:../packages/bash/bash @@ -517,9 +539,6 @@ 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/self-modification/repository-plugin '@deepseek-ai/dsh-sandbox': specifier: workspace:* version: link:../packages/sandbox/sandbox @@ -737,10 +756,13 @@ importers: '@agentclientprotocol/sdk': specifier: 0.25.1 version: 0.25.1(zod@4.4.3) - schemastery: - specifier: ^3.17.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -765,9 +787,6 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/api/gateway: dependencies: @@ -775,6 +794,9 @@ importers: specifier: workspace:^ version: link:../../typert/type-meta devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection @@ -787,9 +809,6 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis zod: specifier: ^4.4.3 version: 4.4.3 @@ -800,6 +819,9 @@ importers: specifier: workspace:^ version: link:../../typert/type-meta devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -818,31 +840,31 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/attachment/attachment: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/attachment/attachment-local: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery sharp: specifier: ^0.35.3 version: 0.35.3(@types/node@22.20.0) devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../attachment @@ -852,12 +874,12 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/bash: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -867,16 +889,16 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/bash-env: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -898,16 +920,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/bash-local: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -923,12 +945,12 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/bash-sandbox: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -953,16 +975,16 @@ importers: '@deepseek-ai/node-addon-landlock-run': specifier: workspace:* version: link:../../../native/landlock-run/packages/entry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/pwsh-local: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -978,12 +1000,12 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/pwsh-sandbox: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../bash @@ -1005,16 +1027,16 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/tool-bash: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1072,16 +1094,16 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bash/tool-pwsh: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -1130,9 +1152,6 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/boot/app-boot: dependencies: @@ -1140,19 +1159,22 @@ importers: specifier: ^4.2.0 version: 4.2.0 devDependencies: - '@cordisjs/plugin-group': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-group': specifier: workspace:^ version: link:../../../vendor/group - '@cordisjs/plugin-hmr': + '@deepseek-ai/cordis-plugin-hmr': specifier: workspace:^ version: link:../../../vendor/hmr - '@cordisjs/plugin-include': + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer '@deepseek-ai/dsh-environment': @@ -1170,17 +1192,32 @@ importers: '@types/js-yaml': specifier: ^4.0.9 version: 4.0.9 - cordis: - specifier: ^4.0.0-rc.7 + + packages/boot/cmdline: + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + commander: + specifier: ^15.0.0 + version: 15.0.0 packages/bundle/base: dependencies: - '@cordisjs/plugin-hmr': - specifier: workspace:* + '@deepseek-ai/cordis-plugin-hmr': + specifier: workspace:^ version: link:../../../vendor/hmr - '@cordisjs/plugin-timer': - specifier: workspace:* + '@deepseek-ai/cordis-plugin-timer': + specifier: workspace:^ version: link:../../../vendor/timer '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -1263,9 +1300,6 @@ importers: '@deepseek-ai/dsh-repeat-tool-guard': specifier: workspace:^ version: link:../../guard/repeat-tool-guard - '@deepseek-ai/dsh-repository-plugin': - specifier: workspace:^ - version: link:../../self-modification/repository-plugin '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../../sandbox/sandbox-local @@ -1317,6 +1351,12 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-subagent-claude-code': + specifier: workspace:^ + version: link:../../subagent/subagent-claude-code + '@deepseek-ai/dsh-subagent-codex': + specifier: workspace:^ + version: link:../../subagent/subagent-codex '@deepseek-ai/dsh-subagent-fork': specifier: workspace:^ version: link:../../subagent/subagent-fork @@ -1411,23 +1451,32 @@ importers: specifier: workspace:^ version: link:../../context/workspace-context devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bundle/headless: dependencies: + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../boot/cmdline '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -1445,9 +1494,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/bundle/web-app: dependencies: @@ -1457,6 +1503,9 @@ importers: '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes + '@deepseek-ai/dsh-app-boot': + specifier: workspace:^ + version: link:../../boot/app-boot '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../../client/connection @@ -1523,6 +1572,9 @@ importers: '@deepseek-ai/dsh-client-ui-subagent': specifier: workspace:^ version: link:../../client/ui-subagent + '@deepseek-ai/dsh-client-ui-task': + specifier: workspace:^ + version: link:../../client/ui-task '@deepseek-ai/dsh-client-ui-theme': specifier: workspace:^ version: link:../../client/ui-theme @@ -1535,6 +1587,9 @@ importers: '@deepseek-ai/dsh-client-ui-workspace': specifier: workspace:^ version: link:../../client/ui-workspace + '@deepseek-ai/dsh-cmdline': + specifier: workspace:^ + version: link:../../boot/cmdline '@deepseek-ai/dsh-code-runtime-worker': specifier: workspace:^ version: link:../../code-runtime/code-runtime-worker @@ -1559,6 +1614,9 @@ importers: '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver + '@deepseek-ai/dsh-message-feedback': + specifier: workspace:^ + version: link:../../feedback/message-feedback '@deepseek-ai/dsh-session-projection-cache': specifier: workspace:^ version: link:../../session/session-projection-cache @@ -1574,10 +1632,19 @@ importers: '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery + commander: + specifier: ^15.0.0 + version: 15.0.0 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader '@deepseek-ai/dsh-bash-env': specifier: workspace:^ version: link:../../bash/bash-env @@ -1587,9 +1654,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/connection: dependencies: @@ -1611,13 +1675,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery ws: specifier: ^8.21.0 version: 8.21.0 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ version: link:../../host/webserver @@ -1627,17 +1694,17 @@ importers: '@types/ws': specifier: ^8.18.1 version: 8.18.1 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/hmr: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-client-modules': @@ -1649,22 +1716,22 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/locale: dependencies: '@deepseek-ai/dsh-client-connection': - specifier: ^0.0.1 + specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1680,16 +1747,16 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/modules: devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-host-webserver': @@ -1698,9 +1765,6 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/runtime: dependencies: @@ -1722,9 +1786,6 @@ importers: '@deepseek-ai/dsh-commands': specifier: workspace:^ version: link:../../interaction/commands - '@deepseek-ai/dsh-compact': - specifier: workspace:^ - version: link:../../compact/compact '@deepseek-ai/dsh-host-apiproxy': specifier: workspace:^ version: link:../../host/apiproxy @@ -1756,6 +1817,9 @@ importers: specifier: ~4.4.7 version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -1768,28 +1832,25 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery packages/client/schema-form: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/test-runtime: dependencies: @@ -1803,6 +1864,9 @@ importers: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.1)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -1824,9 +1888,6 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1836,6 +1897,9 @@ importers: packages/client/ui-agent-preset: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -1869,9 +1933,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1882,6 +1943,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -1912,9 +1976,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -1924,13 +1985,16 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery clsx: specifier: ^2.0.0 version: 2.1.1 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -2000,9 +2064,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2013,6 +2074,9 @@ importers: specifier: ^18.2.0 version: 18.3.1 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2034,12 +2098,12 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/ui-goal: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ version: link:../../api/remotes @@ -2073,9 +2137,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2085,6 +2146,9 @@ importers: packages/client/ui-layout: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2103,15 +2167,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-model: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2145,15 +2209,15 @@ importers: clsx: specifier: ^2.1.1 version: 2.1.1 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-models: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2187,15 +2251,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-permission: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2232,15 +2296,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-plan: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2274,9 +2338,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2344,6 +2405,9 @@ importers: specifier: ^4.3.1 version: 4.3.1 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -2353,9 +2417,6 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/ui-question: dependencies: @@ -2381,6 +2442,9 @@ importers: specifier: ^18.2.0 version: 18.3.1 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -2402,9 +2466,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/ui-settings: dependencies: @@ -2412,6 +2473,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2436,9 +2500,6 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2451,10 +2512,13 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2485,9 +2549,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2498,6 +2559,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2522,15 +2586,15 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-skill: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2564,9 +2628,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2580,6 +2641,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2601,24 +2665,21 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 packages/client/ui-slots: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/client/ui-subagent: dependencies: @@ -2626,6 +2687,9 @@ importers: specifier: ^18.2.0 version: 18.3.1 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2659,25 +2723,59 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 + + packages/client/ui-task: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/dsh-client-runtime': + specifier: workspace:^ + version: link:../runtime + '@deepseek-ai/dsh-client-test-runtime': + specifier: workspace:^ + version: link:../test-runtime + '@deepseek-ai/dsh-client-ui-conversation': + specifier: workspace:^ + version: link:../ui-conversation + '@deepseek-ai/dsh-client-ui-primitives': + specifier: workspace:^ + version: link:../ui-primitives + '@deepseek-ai/dsh-client-ui-slots': + specifier: workspace:^ + version: link:../ui-slots + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@types/react': + specifier: ~18.3.1 + version: 18.3.31 packages/client/ui-theme: dependencies: '@deepseek-ai/dsh-client-connection': - specifier: ^0.0.1 + specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery clsx: specifier: ^2.0.0 version: 2.1.1 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2699,9 +2797,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2712,6 +2807,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ version: link:../connection @@ -2745,9 +2843,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2764,6 +2859,15 @@ importers: specifier: ^9.0.0 version: 9.0.0 devDependencies: + '@deepseek-ai/dsh-client-locale': + specifier: workspace:^ + version: link:../locale + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/dsh-agent': + specifier: workspace:^ + version: link:../../core/agent '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../runtime @@ -2776,18 +2880,21 @@ importers: '@deepseek-ai/dsh-client-ui-slots': specifier: workspace:^ version: link:../ui-slots + '@deepseek-ai/dsh-compact': + specifier: workspace:^ + version: link:../../compact/compact '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants + '@deepseek-ai/dsh-tools': + specifier: workspace:^ + version: link:../../core/tools '@types/react': specifier: ~18.3.1 version: 18.3.31 '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2801,6 +2908,9 @@ importers: specifier: ^2.0.0 version: 2.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../locale @@ -2828,9 +2938,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -2862,7 +2969,10 @@ importers: specifier: ^18.2.0 version: 18.3.1(react@18.3.1) devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-client-runtime': @@ -2880,9 +2990,6 @@ importers: '@types/react-dom': specifier: ~18.3.0 version: 18.3.7(@types/react@18.3.31) - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis typescript: specifier: ^6.0.3 version: 6.0.3 @@ -2899,31 +3006,34 @@ importers: specifier: 1.2.0 version: 1.2.0(react@18.3.1) devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/code-runtime/code-runtime: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/code-runtime/code-runtime-worker: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ version: link:../code-runtime @@ -2936,16 +3046,16 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/compact/command-compact: devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -2966,12 +3076,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/compact/compact: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -2987,20 +3097,20 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/compact/compact-basic: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -3039,20 +3149,20 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/compact/compact-tool-result-prune: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-compact': @@ -3070,16 +3180,16 @@ importers: '@deepseek-ai/dsh-token-meter': specifier: workspace:^ version: link:../../llm/token-meter - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/context/session-reference: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3101,16 +3211,16 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../../session-query/session-query - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/context/time-context: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3138,16 +3248,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/context/tmux-context: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3166,17 +3276,17 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/context/workspace-context: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -3215,12 +3325,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/core/agent: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3242,16 +3352,16 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/agent-default-model: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent @@ -3264,16 +3374,16 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../../settings/settings - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/agent-loop: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent @@ -3301,16 +3411,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/agent-tool-mode: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent @@ -3332,21 +3442,21 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/scope: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/session: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -3365,16 +3475,16 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/system-prompt: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3384,16 +3494,16 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../scope - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/core/tools: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../agent @@ -3418,34 +3528,34 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/credentials/credentials: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/credentials/credentials-local: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery chokidar: specifier: ^4.0.3 version: 4.0.3 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery yaml: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write @@ -3461,19 +3571,19 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/e2b/e2b: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery e2b: specifier: 2.29.1 version: 2.29.1 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -3483,12 +3593,12 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/e2b/fs-e2b: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-e2b': specifier: workspace:^ version: link:../e2b @@ -3498,16 +3608,16 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/e2b/subprocess-e2b: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-e2b': specifier: workspace:^ version: link:../e2b @@ -3520,16 +3630,20 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/examples/acp-demo: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-acp': @@ -3568,20 +3682,17 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../context/workspace-context - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - schemastery: - specifier: ^3.17.0 - version: link:../../../vendor/schemastery packages/examples/agent-spine-demo: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer '@deepseek-ai/dsh-agent': @@ -3683,9 +3794,6 @@ importers: '@deepseek-ai/node-addon-landlock-run': specifier: workspace:* version: link:../../../native/landlock-run/packages/entry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/examples/jsonrpc-demo: dependencies: @@ -3693,19 +3801,22 @@ importers: specifier: workspace:^ version: link:../../boot/app-boot devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/feedback/command-feedback: devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -3723,15 +3834,67 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session + '@deepseek-ai/dsh-session-telemetry': + specifier: workspace:^ + version: link:../../session/session-telemetry '@deepseek-ai/dsh-user-id': specifier: workspace:^ version: link:../../session/user-id - cordis: - specifier: ^4.0.0-rc.7 + + packages/feedback/message-feedback: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery + zod: + specifier: ^4.4.3 + version: 4.4.3 + devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ + version: link:../../../vendor/include + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../../../vendor/loader + '@deepseek-ai/dsh-brand': + specifier: workspace:^ + version: link:../../util/brand + '@deepseek-ai/dsh-invariants': + specifier: workspace:^ + version: link:../../support/invariants + '@deepseek-ai/dsh-llm': + specifier: workspace:^ + version: link:../../llm/llm + '@deepseek-ai/dsh-session': + specifier: workspace:^ + version: link:../../core/session + '@deepseek-ai/dsh-session-persistence': + specifier: workspace:^ + version: link:../../session/session-persistence + '@deepseek-ai/dsh-session-persistence-jsonl': + specifier: workspace:^ + version: link:../../session/session-persistence-jsonl + '@deepseek-ai/dsh-storage': + specifier: workspace:^ + version: link:../../storage/storage + '@deepseek-ai/dsh-storage-domain': + specifier: workspace:^ + version: link:../../storage/storage-domain + '@deepseek-ai/dsh-storage-json': + specifier: workspace:^ + version: link:../../storage/storage-json + '@deepseek-ai/dsh-type-meta': + specifier: workspace:^ + version: link:../../typert/type-meta packages/fs/fs: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -3744,19 +3907,19 @@ importers: '@deepseek-ai/dsh-sandbox': specifier: workspace:^ version: link:../../sandbox/sandbox - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/fs/fs-local: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery koffi: specifier: ^3.1.0 version: 3.1.1 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs @@ -3766,12 +3929,12 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/fs/fs-policy: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs @@ -3781,12 +3944,12 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/fs/fs-sandbox: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs @@ -3802,19 +3965,19 @@ importers: '@deepseek-ai/dsh-sandbox-policy': specifier: workspace:^ version: link:../../sandbox/sandbox-policy - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/fs/tool-fs: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery diff: specifier: ^9.0.0 version: 9.0.0 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3824,6 +3987,9 @@ importers: '@deepseek-ai/dsh-agent-loop-testkit': specifier: workspace:^ version: link:../../support/agent-loop-testkit + '@deepseek-ai/dsh-attachment': + specifier: workspace:^ + version: link:../../attachment/attachment '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../fs @@ -3860,19 +4026,19 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/fs/tool-fs-search: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery '@vscode/ripgrep': specifier: ^1.18.0 version: 1.18.0 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3906,16 +4072,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/fs/tool-str-replace-editor: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -3952,13 +4118,13 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/goal/command-goal: devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -3979,19 +4145,19 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/goal/goal: dependencies: - schemastery: - specifier: ^3.17.2 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4019,12 +4185,12 @@ importers: '@deepseek-ai/dsh-type-meta': specifier: workspace:^ version: link:../../typert/type-meta - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/goal/goal-session: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4052,17 +4218,17 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/goal/tool-goal: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -4086,16 +4252,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/guard/repeat-tool-guard: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4117,12 +4283,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/guard/timeout-policy: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4135,12 +4301,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/hooks/hook-protocol: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash @@ -4150,16 +4316,16 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/hooks/hooks-claude: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4202,16 +4368,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/hooks/hooks-codex: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4251,9 +4417,6 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/host/apiproxy: dependencies: @@ -4317,6 +4480,9 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent + '@deepseek-ai/dsh-tasks': + specifier: workspace:^ + version: link:../../tasks/tasks '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -4329,13 +4495,19 @@ importers: '@deepseek-ai/dsh-workspace': specifier: workspace:^ version: link:../../workspace/workspace - schemastery: - specifier: ^3.18.0 + fflate: + specifier: ^0.8.2 + version: 0.8.3 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ version: link:../../preset/agent-presets @@ -4354,25 +4526,25 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../../typert/registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/host/directory-picker: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/host/directory-picker-auto: devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-host-directory-picker': @@ -4390,22 +4562,22 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/host/directory-picker-browse: dependencies: '@deepseek-ai/dsh-host-directory-picker': specifier: workspace:^ version: link:../directory-picker + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery clsx: specifier: ^2.0.0 version: 2.1.1 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ version: link:../../client/locale @@ -4430,9 +4602,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -4449,6 +4618,9 @@ importers: specifier: ^3.1.0 version: 3.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ version: link:../../client/runtime @@ -4464,9 +4636,6 @@ importers: '@types/react': specifier: ~18.3.1 version: 18.3.31 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis react: specifier: ^18.2.0 version: 18.3.1 @@ -4476,11 +4645,14 @@ importers: packages/host/frontend-static: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-host-webserver': @@ -4489,25 +4661,25 @@ importers: '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/host/webserver: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/interaction/commands: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4523,19 +4695,19 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/interaction/permission: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ version: link:../../bash/bash @@ -4563,12 +4735,12 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/interaction/tool-ask-user: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4587,16 +4759,16 @@ importers: '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../user-interaction - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/interaction/user-approval: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4618,12 +4790,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/interaction/user-interaction: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4633,16 +4805,16 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/llm/llm: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment @@ -4655,19 +4827,19 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/llm/llm-deepseek: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery eventsource-parser: specifier: ^3.1.0 version: 3.1.0 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-credentials': specifier: workspace:^ version: link:../../credentials/credentials @@ -4686,19 +4858,19 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/llm/llm-pi-ai: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery '@earendil-works/pi-ai': specifier: ^0.82.1 version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-attachment': specifier: workspace:^ version: link:../../attachment/attachment @@ -4723,20 +4895,20 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/llm/llm-retry: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -4781,19 +4953,19 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/llm/token-meter: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../../compact/compact @@ -4809,12 +4981,12 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../../session/session-projection - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/lsp/lsp: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -4824,16 +4996,16 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/lsp/lsp-local: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -4861,9 +5033,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis typescript: specifier: ^6.0.3 version: 6.0.3 @@ -4873,10 +5042,13 @@ importers: packages/lsp/tool-lsp: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4913,22 +5085,22 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/mcp/mcp-client: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery '@modelcontextprotocol/sdk': specifier: ^1.12.0 version: 1.29.0(zod@4.4.3) - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -4938,6 +5110,9 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess + '@deepseek-ai/dsh-timeout': + specifier: workspace:^ + version: link:../../util/timeout '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools @@ -4947,9 +5122,6 @@ importers: '@modelcontextprotocol/server-filesystem': specifier: ^2026.7.4 version: 2026.7.10(zod@4.4.3) - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/plan/plan-mode: dependencies: @@ -4957,6 +5129,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -4990,23 +5165,23 @@ importers: '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../interaction/user-interaction - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/preset/agent-presets: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery js-yaml: specifier: ^4.1.0 version: 4.2.0 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -5045,16 +5220,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/preset/persona: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5064,12 +5239,12 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/pty/pty: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5082,16 +5257,16 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/pty/pty-local: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5116,20 +5291,20 @@ importers: '@deepseek-ai/dsh-subprocess-local': specifier: workspace:^ version: link:../../subprocess/subprocess-local - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/pty/tool-bash-persistent: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -5168,20 +5343,20 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/pty/tool-pty: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -5229,12 +5404,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/sandbox/sandbox: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5244,9 +5419,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/sandbox/sandbox-local: dependencies: @@ -5256,10 +5428,13 @@ importers: '@deepseek-ai/node-addon-landlock-run': specifier: workspace:* version: link:../../../native/landlock-run/packages/entry - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5272,16 +5447,16 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/sandbox/sandbox-policy: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5297,9 +5472,6 @@ importers: '@deepseek-ai/dsh-system-prompt': specifier: workspace:^ version: link:../../core/system-prompt - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/sandbox/sandbox-windows-acl: dependencies: @@ -5307,6 +5479,9 @@ importers: specifier: ^3.1.0 version: 3.1.1 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5316,12 +5491,12 @@ importers: '@deepseek-ai/dsh-sandbox-local': specifier: workspace:^ version: link:../sandbox-local - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/scaffold/client: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5334,9 +5509,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/scaffold/create-sdk: dependencies: @@ -5347,12 +5519,12 @@ importers: specifier: ^15.0.0 version: 15.0.0 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/scaffold/helper: dependencies: @@ -5372,6 +5544,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5402,12 +5577,12 @@ importers: '@deepseek-ai/dsh-tool-web': specifier: workspace:^ version: link:../../web/tool-web - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/scaffold/protocol: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5420,9 +5595,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/scaffold/scripts: dependencies: @@ -5439,15 +5611,15 @@ importers: specifier: ^0.1.4 version: 0.1.4 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-app-boot': specifier: workspace:^ version: link:../../boot/app-boot '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - 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) @@ -5457,11 +5629,14 @@ importers: packages/scaffold/server: dependencies: - schemastery: - specifier: ^3.17.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -5494,9 +5669,6 @@ importers: '@deepseek-ai/dsh-subagent': specifier: workspace:^ version: link:../../subagent/subagent - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/scaffold/telemetry: dependencies: @@ -5504,6 +5676,9 @@ importers: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5513,54 +5688,20 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - - packages/self-modification/repository-plugin: - dependencies: - zod: - specifier: ^4.4.3 - version: 4.4.3 - devDependencies: - '@cordisjs/plugin-loader': - specifier: workspace:^ - version: link:../../../vendor/loader - '@deepseek-ai/dsh-invariants': - specifier: workspace:^ - version: link:../../support/invariants - '@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 - '@deepseek-ai/dsh-skill-local': - specifier: workspace:^ - version: link:../../skill/skill-local - '@deepseek-ai/dsh-system-prompt': - specifier: workspace:^ - version: link:../../core/system-prompt - '@deepseek-ai/dsh-tools': - specifier: workspace:^ - version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/self-modification/tool-cordis: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ version: link:../../../vendor/loader - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../../vendor/timer '@deepseek-ai/dsh-agent': @@ -5590,12 +5731,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session-query/session-query: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5614,17 +5755,17 @@ importers: '@deepseek-ai/dsh-session-title': specifier: workspace:^ version: link:../../session/session-title - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session-query/session-query-sqlite: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-invariants': @@ -5642,16 +5783,16 @@ importers: '@deepseek-ai/dsh-session-query': specifier: workspace:^ version: link:../session-query - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session-query/tool-session-query: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5691,13 +5832,13 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-checkpoint-policy: devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -5730,12 +5871,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-persistence: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5751,19 +5892,19 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-persistence-jsonl: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery koffi: specifier: ^3.1.0 version: 3.1.1 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5773,16 +5914,16 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../session-persistence - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-persistence-sqlite: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5792,9 +5933,6 @@ importers: '@deepseek-ai/dsh-session-persistence': specifier: workspace:^ version: link:../session-persistence - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-projection: dependencies: @@ -5802,25 +5940,28 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-projection-cache: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5839,12 +5980,12 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-telemetry: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -5854,12 +5995,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-telemetry-otel: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery '@opentelemetry/api': specifier: ^1.9.1 version: 1.9.1 @@ -5878,11 +6019,11 @@ importers: '@opentelemetry/sdk-logs': specifier: ^0.220.0 version: 0.220.0(@opentelemetry/api@1.9.1) - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-command-feedback': @@ -5903,19 +6044,19 @@ importers: '@deepseek-ai/dsh-user-id': specifier: workspace:^ version: link:../user-id - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-title: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -5937,16 +6078,16 @@ importers: '@deepseek-ai/dsh-session-projection': specifier: workspace:^ version: link:../session-projection - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-title-all-messages-llm: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -5962,20 +6103,20 @@ importers: '@deepseek-ai/dsh-session-title-llm': specifier: workspace:^ version: link:../session-title-llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-title-first-message-llm: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-invariants': @@ -5996,16 +6137,16 @@ importers: '@deepseek-ai/dsh-session-title-llm': specifier: workspace:^ version: link:../session-title-llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/session-title-llm: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6021,12 +6162,12 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/session/user-id: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -6036,37 +6177,38 @@ importers: '@deepseek-ai/dsh-paths': specifier: workspace:^ version: link:../../util/paths - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/settings/settings: + dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery packages/settings/settings-local: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery chokidar: specifier: ^4.0.3 version: 4.0.3 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery yaml: specifier: ^2.9.0 version: 2.9.0 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ version: link:../../util/atomic-write @@ -6079,16 +6221,16 @@ importers: '@deepseek-ai/dsh-settings': specifier: workspace:^ version: link:../settings - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/skill/skill: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6098,34 +6240,34 @@ importers: '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/skill/skill-badge: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/skill/skill-local: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery chokidar: specifier: ^5.0.0 version: 5.0.0 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery yaml: specifier: ^2.4.2 version: 2.9.0 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-fs': specifier: workspace:^ version: link:../../fs/fs @@ -6138,16 +6280,16 @@ importers: '@deepseek-ai/dsh-skill': specifier: workspace:^ version: link:../skill - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/skill/tool-skill: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6172,12 +6314,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/spill/spill: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -6190,16 +6332,16 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/spill/spill-local: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -6215,16 +6357,16 @@ importers: '@deepseek-ai/dsh-spill': specifier: workspace:^ version: link:../spill - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/spill/spill-policy: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6249,69 +6391,66 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/storage/storage: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/storage/storage-domain: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../storage - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/storage/storage-json: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../storage - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/storage/storage-sqlite: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-storage': specifier: workspace:^ version: link:../storage - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent: dependencies: @@ -6319,6 +6458,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6334,6 +6476,12 @@ importers: '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm + '@deepseek-ai/dsh-sandbox': + specifier: workspace:^ + version: link:../../sandbox/sandbox + '@deepseek-ai/dsh-sandbox-policy': + specifier: workspace:^ + version: link:../../sandbox/sandbox-policy '@deepseek-ai/dsh-scope': specifier: workspace:^ version: link:../../core/scope @@ -6361,21 +6509,24 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis + '@deepseek-ai/dsh-user-approval': + specifier: workspace:^ + version: link:../../interaction/user-approval packages/subagent/subagent-acp: dependencies: '@agentclientprotocol/sdk': specifier: 0.25.1 version: 0.25.1(zod@4.4.3) - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6404,9 +6555,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-claude-code: dependencies: @@ -6416,10 +6564,13 @@ importers: '@anthropic-ai/sdk': specifier: 0.93.0 version: 0.93.0(zod@4.4.3) - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6447,18 +6598,18 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-codex: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6493,18 +6644,18 @@ importers: '@openai/codex': specifier: 0.147.0 version: 0.147.0 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-dsh-sdk: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6533,18 +6684,18 @@ importers: '@deepseek-ai/dsh-subprocess': specifier: workspace:^ version: link:../../subprocess/subprocess - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-fork: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6573,17 +6724,17 @@ importers: '@deepseek-ai/dsh-subagent-spawn': specifier: workspace:^ version: link:../subagent-spawn - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-inprocess: devDependencies: - '@cordisjs/plugin-include': - specifier: ^1.0.4 + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6627,18 +6778,18 @@ importers: '@deepseek-ai/dsh-user-approval': specifier: workspace:^ version: link:../../interaction/user-approval - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/subagent-spawn: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6679,18 +6830,18 @@ importers: '@deepseek-ai/dsh-tool-subagent': specifier: workspace:^ version: link:../tool-subagent - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/tool-subagent: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6731,12 +6882,12 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/tool-subagent-control: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6773,16 +6924,16 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subagent/tool-subagent-report: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6819,18 +6970,15 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subprocess/subprocess: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/subprocess/subprocess-local: dependencies: @@ -6838,6 +6986,9 @@ importers: specifier: ^1.1.0 version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -6847,9 +6998,6 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/support/acp-snapshot: dependencies: @@ -6863,18 +7011,21 @@ importers: specifier: ^4.1.8 version: 4.1.8(@opentelemetry/api@1.9.0)(@types/node@25.9.3)(@vitest/coverage-v8@4.1.8)(jsdom@29.1.1)(vite@8.0.16(@types/node@26.1.2)(esbuild@0.28.1)(jiti@2.7.0)(tsx@4.22.4)(yaml@2.9.0)) devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../invariants '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/support/agent-loop-testkit: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6896,31 +7047,31 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/support/invariants: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - cordis: - specifier: ^4.0.0-rc.7 + '@deepseek-ai/cordis': + specifier: workspace:^ version: link:../../../vendor/cordis packages/support/llm-mock-server: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/support/llm-replay: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-compact': specifier: workspace:^ version: link:../../compact/compact @@ -6933,9 +7084,6 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/support/loader-smoke: dependencies: @@ -6946,6 +7094,9 @@ importers: specifier: ^4.22.4 version: 4.22.4 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6958,12 +7109,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/tasks/tasks: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -6976,12 +7127,12 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/tasks/tasks-local: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7003,16 +7154,16 @@ importers: '@deepseek-ai/dsh-timeout': specifier: workspace:^ version: link:../../util/timeout - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/tasks/tool-tasks: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7040,23 +7191,23 @@ importers: '@deepseek-ai/dsh-tools': specifier: workspace:^ version: link:../../core/tools - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/todo/tool-todo: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery zod: specifier: ^4.4.3 version: 4.4.3 devDependencies: - '@cordisjs/plugin-include': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -7092,9 +7243,6 @@ importers: '@deepseek-ai/dsh-user-interaction': specifier: workspace:^ version: link:../../interaction/user-interaction - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/typert/generator: dependencies: @@ -7105,6 +7253,9 @@ importers: specifier: ^6.0.3 version: 6.0.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -7114,20 +7265,20 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis zod: specifier: ^4.4.3 version: 4.4.3 packages/typert/loader: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-invariants': @@ -7136,9 +7287,6 @@ importers: '@deepseek-ai/dsh-typert-registry': specifier: workspace:^ version: link:../registry - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis zod: specifier: ^4.4.3 version: 4.4.3 @@ -7152,97 +7300,100 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/typert/type-meta: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/util/atomic-write: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/util/brand: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/util/environment: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/util/native-command: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/util/paths: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/util/retention: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.6 - version: link:../../../vendor/cordis packages/util/timeout: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/web/tool-web: dependencies: + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery + version: link:../../../vendor/schemastery '@joplin/turndown-plugin-gfm': specifier: ^1.0.67 version: 1.0.67 - schemastery: - specifier: ^3.18.0 - version: link:../../../vendor/schemastery turndown: specifier: ^7.2.4 version: 7.2.4 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7282,32 +7433,32 @@ importers: '@types/turndown': specifier: ^5.0.6 version: 5.0.6 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/web/web: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants '@deepseek-ai/dsh-llm': specifier: workspace:^ version: link:../../llm/llm - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/web/web-fetch-local: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ version: link:../../support/invariants @@ -7317,16 +7468,16 @@ importers: '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/web/web-search-deepseek: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7348,16 +7499,16 @@ importers: '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/web/web-search-exa: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-environment': specifier: workspace:^ version: link:../../util/environment @@ -7367,16 +7518,16 @@ importers: '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/web/web-search-perplexity: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-environment': specifier: workspace:^ version: link:../../util/environment @@ -7386,17 +7537,17 @@ importers: '@deepseek-ai/dsh-web': specifier: workspace:^ version: link:../web - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/workflow/tool-ralph: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': @@ -7438,16 +7589,16 @@ importers: '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:^ version: link:../workflow-workerthread - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/workflow/tool-workflow: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7475,12 +7626,12 @@ importers: '@deepseek-ai/dsh-workflow-workerthread': specifier: workspace:^ version: link:../workflow-workerthread - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/workflow/workflow: devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7496,16 +7647,16 @@ importers: '@deepseek-ai/dsh-session': specifier: workspace:^ version: link:../../core/session - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis packages/workflow/workflow-workerthread: dependencies: - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../../../vendor/schemastery version: link:../../../vendor/schemastery devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ version: link:../../core/agent @@ -7542,9 +7693,6 @@ importers: '@deepseek-ai/dsh-workflow': specifier: workspace:^ version: link:../workflow - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis tsx: specifier: ^4.19.2 version: 4.22.4 @@ -7555,6 +7703,9 @@ importers: specifier: ^4.4.3 version: 4.4.3 devDependencies: + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ version: link:../../util/brand @@ -7573,24 +7724,27 @@ importers: '@deepseek-ai/dsh-storage-domain': specifier: workspace:^ version: link:../../storage/storage-domain - cordis: - specifier: ^4.0.0-rc.7 - version: link:../../../vendor/cordis python/sdk-runtime: dependencies: - '@cordisjs/plugin-group': + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../../vendor/cordis + '@deepseek-ai/cordis-plugin-group': specifier: workspace:^ version: link:../../vendor/group - '@cordisjs/plugin-include': + '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ version: link:../../vendor/include - '@cordisjs/plugin-loader': + '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ version: link:../../vendor/loader - '@cordisjs/plugin-timer': + '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ version: link:../../vendor/timer + '@deepseek-ai/cosmokit': + specifier: link:../../vendor/cosmokit + version: link:../../vendor/cosmokit '@deepseek-ai/dsh-acp': specifier: workspace:^ version: link:../../packages/acp/acp @@ -7657,6 +7811,9 @@ importers: '@deepseek-ai/dsh-fs-policy': specifier: workspace:^ version: link:../../packages/fs/fs-policy + '@deepseek-ai/dsh-fs-sandbox': + specifier: workspace:^ + version: link:../../packages/fs/fs-sandbox '@deepseek-ai/dsh-goal': specifier: workspace:^ version: link:../../packages/goal/goal @@ -7885,62 +8042,59 @@ importers: '@deepseek-ai/dsh-workspace-context': specifier: workspace:^ version: link:../../packages/context/workspace-context - cordis: - specifier: workspace:^ - version: link:../../vendor/cordis - schemastery: - specifier: workspace:^ + '@deepseek-ai/schemastery': + specifier: link:../../vendor/schemastery version: link:../../vendor/schemastery vendor/cordis: dependencies: - '@cordisjs/plugin-include': - specifier: ^1.0.4 + '@deepseek-ai/cordis-plugin-include': + specifier: workspace:^ version: link:../include - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ version: link:../loader + '@deepseek-ai/cosmokit': + specifier: link:../cosmokit + version: link:../cosmokit '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 - cosmokit: - specifier: ^1.8.1 - version: link:../cosmokit vendor/cosmokit: {} vendor/group: dependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 - version: link:../loader - cordis: - specifier: ^4.0.0-rc.7 + '@deepseek-ai/cordis': + specifier: workspace:^ version: link:../cordis + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../loader vendor/hmr: dependencies: '@babel/code-frame': specifier: ^7.29.0 version: 7.29.7 - '@cordisjs/plugin-timer': - specifier: ^1.1.2 + '@deepseek-ai/cordis': + specifier: workspace:^ + version: link:../cordis + '@deepseek-ai/cordis-plugin-timer': + specifier: workspace:^ version: link:../timer + '@deepseek-ai/cosmokit': + specifier: link:../cosmokit + version: link:../cosmokit + '@deepseek-ai/schemastery': + specifier: link:../schemastery + version: link:../schemastery chokidar: specifier: ^4.0.3 version: 4.0.3 - cordis: - specifier: ^4.0.0-rc.7 - version: link:../cordis - cosmokit: - specifier: ^1.8.1 - version: link:../cosmokit picomatch: specifier: ^4.0.3 version: 4.0.4 - schemastery: - specifier: ^3.18.0 - version: link:../schemastery devDependencies: '@types/babel__code-frame': specifier: ^7.27.0 @@ -7954,14 +8108,14 @@ importers: vendor/include: dependencies: - '@cordisjs/plugin-loader': - specifier: ^1.0.0-rc.5 - version: link:../loader - cordis: - specifier: ^4.0.0-rc.7 + '@deepseek-ai/cordis': + specifier: workspace:^ version: link:../cordis - cosmokit: - specifier: ^1.8.1 + '@deepseek-ai/cordis-plugin-loader': + specifier: workspace:^ + version: link:../loader + '@deepseek-ai/cosmokit': + specifier: link:../cosmokit version: link:../cosmokit js-yaml: specifier: ^4.1.0 @@ -7969,29 +8123,26 @@ importers: vendor/loader: dependencies: - cordis: - specifier: ^4.0.0-rc.7 + '@deepseek-ai/cordis': + specifier: workspace:^ version: link:../cordis - cosmokit: - specifier: ^1.8.1 + '@deepseek-ai/cosmokit': + specifier: link:../cosmokit version: link:../cosmokit node-addon-require-builtin: specifier: ^0.1.4 version: 0.1.4 - pnpm: - specifier: 11.7.0 - version: 11.7.0 vendor/logger-console: dependencies: - cordis: - specifier: ^4.0.0-rc.7 + '@deepseek-ai/cordis': + specifier: workspace:^ version: link:../cordis - cosmokit: - specifier: ^1.8.1 + '@deepseek-ai/cosmokit': + specifier: link:../cosmokit version: link:../cosmokit - schemastery: - specifier: ^3.18.0 + '@deepseek-ai/schemastery': + specifier: link:../schemastery version: link:../schemastery supports-color: specifier: ^9.4.0 @@ -7999,20 +8150,20 @@ importers: vendor/schemastery: dependencies: + '@deepseek-ai/cosmokit': + specifier: link:../cosmokit + version: link:../cosmokit '@standard-schema/spec': specifier: ^1.1.0 version: 1.1.0 - cosmokit: - specifier: ^1.8.1 - version: link:../cosmokit vendor/timer: dependencies: - cordis: - specifier: ^4.0.0-rc.7 + '@deepseek-ai/cordis': + specifier: workspace:^ version: link:../cordis - cosmokit: - specifier: ^1.8.1 + '@deepseek-ai/cosmokit': + specifier: link:../cosmokit version: link:../cosmokit website: @@ -11570,6 +11721,9 @@ packages: resolution: {integrity: sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==} engines: {node: ^12.20 || >= 14.13} + fflate@0.8.3: + resolution: {integrity: sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==} + figures@6.1.0: resolution: {integrity: sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg==} engines: {node: '>=18'} @@ -12638,11 +12792,6 @@ 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==} @@ -16867,6 +17016,8 @@ snapshots: node-domexception: 1.0.0 web-streams-polyfill: 3.3.3 + fflate@0.8.3: {} + figures@6.1.0: dependencies: is-unicode-supported: 2.1.0 @@ -18146,8 +18297,6 @@ 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/pnpm-workspace.yaml b/pnpm-workspace.yaml index 66510d89ec..e8d8ee5bec 100644 --- a/pnpm-workspace.yaml +++ b/pnpm-workspace.yaml @@ -24,6 +24,10 @@ packages: # builds must resolve those matching names to this workspace's pinned sources. linkWorkspacePackages: true +overrides: + '@deepseek-ai/cosmokit': 'link:vendor/cosmokit' + '@deepseek-ai/schemastery': 'link:vendor/schemastery' + peerDependencyRules: allowedVersions: typescript: '>=5 <7' @@ -48,13 +52,9 @@ allowBuilds: koffi: true # The Python runtime deploy includes the reviewed workspace postinstall that # restores the executable bit on node-pty's macOS spawn helper. - '@deepseek-ai/dsh-pty-local@file:packages/pty/pty-local': true + '@deepseek-ai/dsh-subprocess-local@file:packages/subprocess/subprocess-local': true minimumReleaseAgeExclude: - # Cordis release candidates are source-vendored and pinned in vendor/README.md - # during the same-day sync that updates package manifests and the lockfile. - - '@cordisjs/plugin-loader@1.0.0-rc.5' - - cordis@4.0.0-rc.7 # Fresh pi-ai releases carry the model catalog updates that are the whole # point of bumping it; waiting out the release age would defeat that. - '@earendil-works/pi-ai@0.82.1' diff --git a/python/README.i18n.yaml b/python/README.i18n.yaml index 0086d8519f..ab8ad1f4f8 100644 --- a/python/README.i18n.yaml +++ b/python/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 python/README.md -README.md: 6ab9de681471c4be3bff72ddbf6ce8f118224d4d -README.zh.md: 82fca597791f19caa21a7a433e533a4d47c64ccb +README.md: 75276a915eb4b63f84e0876de46e6d8d63540b59 +README.zh.md: 7791231f9899bd1cca0d62ad35e388db608294c2 diff --git a/python/README.md b/python/README.md index 6ab9de6814..75276a915e 100644 --- a/python/README.md +++ b/python/README.md @@ -8,7 +8,7 @@ Python packages for driving DeepSeek Harness as a subprocess. The client SDK com | Directory | Dist / module | Role | |---|---|---| -| [sdk](sdk/README.md) | `deepseek-harness` / `deepseek_harness` | High-level turns API and lower-level JSON-RPC client | +| [sdk](sdk/README.md) | `deepseek-harness-sdk` / `deepseek_harness` | High-level turns API and lower-level JSON-RPC client | | [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | Bundled runtime binaries and default agent configuration | ## Behavior diff --git a/python/README.zh.md b/python/README.zh.md index 82fca59779..7791231f98 100644 --- a/python/README.zh.md +++ b/python/README.zh.md @@ -8,7 +8,7 @@ | 目录 | 分发名 / 模块 | 职责 | |---|---|---| -| [sdk](sdk/README.md) | `deepseek-harness` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | +| [sdk](sdk/README.md) | `deepseek-harness-sdk` / `deepseek_harness` | 高层轮次 API 与低层 JSON-RPC 客户端 | | [sdk-runtime](sdk-runtime/README.md) | `deepseek-harness-runtime-bin` / `deepseek_harness_runtime` | 内置运行时二进制与默认 agent(智能体)配置 | ## 行为 diff --git a/python/development.i18n.yaml b/python/development.i18n.yaml index 1a7b57f86d..c341c32cea 100644 --- a/python/development.i18n.yaml +++ b/python/development.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent state. Both languages carry equal authority; # after editing either side, bring the other along and re-record with: # pnpm run verify-translation-pairing --write python/development.md -development.md: b0d4875f0d5b7c8fd2b4b480ac67793741640710 -development.zh.md: 053cd1d022ef5fc7d8cff98b9fc3234df6ff4cd1 +development.md: 9614c06436ab6863a5e1b2ff83fbe605552dc13b +development.zh.md: 1c646ca39735b85a5d380768fe215c92532be7e7 diff --git a/python/development.md b/python/development.md index b0d4875f0d..9614c06436 100644 --- a/python/development.md +++ b/python/development.md @@ -55,7 +55,7 @@ Build the pure SDK wheel once and one runtime wheel on each native platform: version="$(node -p "require('./package.json').version")" python scripts/build-python-release.py --package sdk --output-dir dist-python python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness=="$version" +pip install --find-links dist-python deepseek-harness-sdk=="$version" ``` The runtime distribution is wheel-only. The release pipeline publishes three platform wheels with the pure SDK wheel: Linux x64, Linux arm64, and macOS arm64. A `python-vX.Y.Z` tag is accepted only when it matches the repository version. diff --git a/python/development.zh.md b/python/development.zh.md index 053cd1d022..1c646ca397 100644 --- a/python/development.zh.md +++ b/python/development.zh.md @@ -55,7 +55,7 @@ with DeepSeekHarness() as harness: version="$(node -p "require('./package.json').version")" python scripts/build-python-release.py --package sdk --output-dir dist-python python scripts/build-python-release.py --package runtime --platform macos-arm64 --runtime-exe dist-exe/dsh-jsonrpc-agent-pkg-macos-arm64 --output-dir dist-python -pip install --find-links dist-python deepseek-harness=="$version" +pip install --find-links dist-python deepseek-harness-sdk=="$version" ``` 运行时分发包仅提供 wheel 包。发布流水线会连同纯 SDK wheel 包一起发布三个平台 wheel 包:Linux x64、Linux arm64 和 macOS arm64。只有与仓库版本匹配时,才接受 `python-vX.Y.Z` 标签。 diff --git a/python/sdk-runtime/README.i18n.yaml b/python/sdk-runtime/README.i18n.yaml index bc52b5ee6c..8af8d3e1b9 100644 --- a/python/sdk-runtime/README.i18n.yaml +++ b/python/sdk-runtime/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 python/sdk-runtime/README.md -README.md: 07bb3c574b3cd49f1dc74f0e9d9bd1bb7ca9b216 -README.zh.md: 0613b6faf68ea3bdb6c9b673677fc483b79dab72 +README.md: 912d88938c1a8b3c79dad19ab449c7f912d57e22 +README.zh.md: 5b82f33cfe1413e4fb6ceded04d9b6feca4c94ca diff --git a/python/sdk-runtime/README.md b/python/sdk-runtime/README.md index 07bb3c574b..912d88938c 100644 --- a/python/sdk-runtime/README.md +++ b/python/sdk-runtime/README.md @@ -2,14 +2,14 @@ English | [中文](README.zh.md) -Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness` client spawns, and ships the default configuration behind zero-config runs. +Runtime carrier package for the Python SDK (dist `deepseek-harness-runtime-bin`, module `deepseek_harness_runtime`): it locates the bundled runtime binaries the `deepseek-harness-sdk` client spawns, and ships the default configuration behind zero-config runs. ## Runtime carriers Two carriers coexist under `src/deepseek_harness_runtime/runtime/`, both injected by the repo's `scripts/build-exe-for-python-sdk.ts` build and both gitignored: - **exe (production)** — a single-file Node executable `dsh-jsonrpc-agent-pkg--` (platform: `linux`/`macos`; arch: `x64`/`arm64`). macOS builds also ship the native `-spawn-helper` sibling that `node-pty` uses there. No Node installation is needed on the target machine. This is the only carrier that ships in wheel distributions; this package does not publish sdists. -- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. +- **node (dev-only)** — the full deploy closure under `runtime/node/` (`package.json` + `node_modules/`), executed as `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` on a system Node >= 22.19. It is the current checkout's source build, meant for repo-local development and verification only; it is never selected automatically and is excluded from distributions. Both carriers hold the same content, defined once: the [package.json](package.json) at this package's root is the deploy root of the single-exe pipeline — a pure dependency manifest (no code of its own) whose dependency closure IS both the plugin set compiled into the exe and the tree materialized into `runtime/node/`. Adding a plugin to the distribution means adding one dependency line there and rebuilding. diff --git a/python/sdk-runtime/README.zh.md b/python/sdk-runtime/README.zh.md index 0613b6faf6..5b82f33cfe 100644 --- a/python/sdk-runtime/README.zh.md +++ b/python/sdk-runtime/README.zh.md @@ -2,14 +2,14 @@ [English](README.md) | 中文 -Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 +Python SDK 的运行时载体包(分发名 `deepseek-harness-runtime-bin`,模块名 `deepseek_harness_runtime`):它定位 `deepseek-harness-sdk` 客户端要 spawn 的内置运行时二进制,并附带支撑零配置运行的默认配置。 ## 运行时载体 两种载体并存于 `src/deepseek_harness_runtime/runtime/` 之下,均由仓库的 `scripts/build-exe-for-python-sdk.ts` 构建注入,且均被 git 忽略: - **exe(生产)**——单文件 Node 可执行程序 `dsh-jsonrpc-agent-pkg--`(platform:`linux`/`macos`;arch:`x64`/`arm64`)。macOS 构建还会随附 `node-pty` 在该平台使用的原生 `-spawn-helper` 伴随文件。目标机器无需安装 Node。这是唯一随 wheel 包分发的载体;本包不发布 sdist。 -- **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 +- **node(仅限开发)**——`runtime/node/` 下的完整部署闭包(`package.json` + `node_modules/`),在系统 Node >= 22.19 上以 `node runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js` 执行。它是当前检出的源码构建,仅用于仓库本地的开发与验证;不会被自动选中,也不进入分发物。 两种载体承载相同的内容,且只定义一次:本包根目录的 [package.json](package.json) 是 single-exe 流水线的部署根目录——一份零代码的纯依赖 manifest,其依赖闭包既是编译进 exe 的插件集,也是物化到 `runtime/node/` 的文件树。往分发物里加插件,就是在那里加一行依赖再重新构建。 diff --git a/python/sdk-runtime/package.json b/python/sdk-runtime/package.json index d5e90fb1b9..4c1c8d7ea2 100644 --- a/python/sdk-runtime/package.json +++ b/python/sdk-runtime/package.json @@ -5,10 +5,11 @@ "private": true, "type": "module", "dependencies": { - "@cordisjs/plugin-group": "workspace:^", - "@cordisjs/plugin-include": "workspace:^", - "@cordisjs/plugin-loader": "workspace:^", - "@cordisjs/plugin-timer": "workspace:^", + "@deepseek-ai/cordis-plugin-group": "workspace:^", + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis-plugin-timer": "workspace:^", + "@deepseek-ai/cosmokit": "workspace:^", "@deepseek-ai/dsh-acp": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", @@ -31,6 +32,7 @@ "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-fs-local": "workspace:^", "@deepseek-ai/dsh-fs-policy": "workspace:^", + "@deepseek-ai/dsh-fs-sandbox": "workspace:^", "@deepseek-ai/dsh-goal": "workspace:^", "@deepseek-ai/dsh-goal-session": "workspace:^", "@deepseek-ai/dsh-hook-protocol": "workspace:^", @@ -107,7 +109,7 @@ "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "cordis": "workspace:^", - "schemastery": "workspace:^" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py index a3aa53ae80..5f3a94b3e3 100644 --- a/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py +++ b/python/sdk-runtime/src/deepseek_harness_runtime/__init__.py @@ -9,7 +9,7 @@ Two runtime carriers coexist under ``runtime/``, both injected by the repo's needs no Node installation. - **node (dev-only)**: the full deploy closure under ``runtime/node/`` (``package.json`` + ``node_modules/``), executed as ``node - runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js`` on a + runtime/node/node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js`` on a system Node >= 22.19. It is the current checkout's source build, never selected automatically, and excluded from wheel/sdist distributions. @@ -131,7 +131,12 @@ def _current_platform_tag() -> str: def _node_launch_args() -> tuple[str, str]: node_root = bundled_package_dir() / "runtime" / "node" bin_js = ( - node_root / "node_modules" / "@deepseek-ai" / "dsh-jsonrpc-demo" / "lib" / "bin.js" + node_root + / "node_modules" + / "@deepseek-ai" + / "dsh-jsonrpc-demo" + / "lib" + / "packaged-bin.js" ) if not bin_js.is_file(): raise FileNotFoundError( diff --git a/python/sdk/README.i18n.yaml b/python/sdk/README.i18n.yaml index 6467b4ca87..895fea6cfc 100644 --- a/python/sdk/README.i18n.yaml +++ b/python/sdk/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 python/sdk/README.md -README.md: 2d545688c58a2f1b755e647d7cda9555249e41c4 -README.zh.md: b335d75aedc3a145771b23ea5d408315cae9a3e3 +README.md: 9640c7e8dfd011b94acdc781ae0e4fdc8ad87378 +README.zh.md: 47ac04f9083ef41e23fda8ec527c1da160fe4769 diff --git a/python/sdk/README.md b/python/sdk/README.md index 2d545688c5..9640c7e8df 100644 --- a/python/sdk/README.md +++ b/python/sdk/README.md @@ -5,10 +5,15 @@ English | [中文](README.zh.md) Python subprocess SDK for driving DeepSeek Harness over JSON-RPC stdio. The runtime inherits normal DeepSeek Harness environment variables such as `DEEPSEEK_BASE_URL` and `DEEPSEEK_API_KEY`, so callers can use real model -endpoints directly or point those variables at a local proxy during -benchmark runs. +endpoints directly or point those variables at a local proxy. -Installing `deepseek-harness` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: +Install the `deepseek-harness-sdk` distribution from PyPI; the import module remains `deepseek_harness`: + +```sh +python -m pip install deepseek-harness-sdk +``` + +Installing `deepseek-harness-sdk` installs the exact same-version `deepseek-harness-runtime-bin` platform wheel. The normal entry point therefore needs no executable argument: ```py from deepseek_harness import DeepSeekHarness @@ -35,6 +40,8 @@ with DeepSeekHarness( `provider` selects a provider route registered by the chosen Cordis composition; `model` is the model id resolved by that adapter. `max_tokens` is an optional positive per-request output-token cap for the root agent and its in-process descendants; omission leaves the provider default in control. Compaction summaries keep the separate limit configured by their compaction plugin. The bundled default composition registers `deepseek-official`. A custom composition can mount `llm-pi-ai`, configure provider-specific credentials/endpoints there, and select any provider/model present in pi-ai's installed catalog. +The [Python SDK tutorial](../../docs/user/guide/python-sdk.md) uses a complete standalone Cordis file to demonstrate installation, direct SDK usage, and runs without the Web UI. + `Session.run()` owns an activity interval from its prompt's durable inbox receipt through the next whole-agent idle and returns `RunResult(session_id, final_response, events, notifications, session_root)`. The result has no prompt-level status or turn reason: `final_response` is the last committed root-session assistant text in the interval, not an output causally assigned to the prompt. Steering, injected context, and other queued work may contribute before idle. `HarnessClient` retains discovered subagent ancestry for the lifetime of the runtime process. During each `Session.run()`, `RunResult.notifications` and `on_notification` receive the root session and all known descendant notifications in wire order, including nested subagent lifecycle and session events. `RunResult.events` contains root-session events only, so descendant messages cannot replace the root response. The low-level `session_prompt()` returns the queued `MessageId` immediately; callers that bypass `Session.run()` own any later activity boundary themselves. diff --git a/python/sdk/README.zh.md b/python/sdk/README.zh.md index b335d75aed..47ac04f908 100644 --- a/python/sdk/README.zh.md +++ b/python/sdk/README.zh.md @@ -2,9 +2,15 @@ [English](README.md) | 中文 -通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接用真实模型端点,也可以在跑基准测试时把它们指向本地代理。 +通过 JSON-RPC stdio 驱动 DeepSeek Harness 的 Python 子进程 SDK。运行时继承常规的 DeepSeek Harness 环境变量(如 `DEEPSEEK_BASE_URL` 与 `DEEPSEEK_API_KEY`),调用方可以直接使用真实模型端点,也可以把这些变量指向本地代理。 -安装 `deepseek-harness` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: +请从 PyPI 安装 `deepseek-harness-sdk` 分发包;导入模块仍为 `deepseek_harness`: + +```sh +python -m pip install deepseek-harness-sdk +``` + +安装 `deepseek-harness-sdk` 会同时安装版本完全相同的 `deepseek-harness-runtime-bin` 平台 wheel 包。因此常规入口不需要传可执行文件参数: ```py from deepseek_harness import DeepSeekHarness @@ -31,6 +37,8 @@ with DeepSeekHarness( `provider` 用于选择当前 Cordis 组合已注册的提供方路由;`model` 是该适配器解析的模型 ID。`max_tokens` 是可选的正整数,用于限制根 agent(智能体)及其进程内后代每次请求的输出 token;省略时由提供方默认值控制。压缩摘要继续使用压缩插件单独配置的上限。内置默认组合注册 `deepseek-official`。自定义组合可以挂载 `llm-pi-ai`,在其中配置各提供方的凭据与端点,再选择 pi-ai 已安装目录中的任意提供方/模型组合。 +[Python SDK 教程](../../docs/user/guide/python-sdk.md)使用完整的独立 Cordis 文件演示安装方式、直接调用 SDK,以及在不使用 Web UI 的情况下运行 agent。 + `Session.run()` 拥有一个从提示词进入持久 inbox 时开始、到整个 agent 下一次进入空闲状态为止的活动区间,并返回 `RunResult(session_id, final_response, events, notifications, session_root)`。结果不携带提示词级状态或轮次原因:`final_response` 是该区间内根会话最后提交的助手文本,并非因果上归属于该提示词的输出。steering(中途引导)、注入的上下文和其他排队工作都可能在进入空闲状态前参与其中。 `HarnessClient` 会在运行时进程的生命周期内保留已发现的 subagent(子 agent)祖先关系。每次执行 `Session.run()` 时,`RunResult.notifications` 与 `on_notification` 会按协议传输顺序收到根会话及所有已知后代的通知,其中包括嵌套 subagent 的生命周期事件与会话事件。`RunResult.events` 只包含根会话事件,因此后代消息不会覆盖根会话回复。底层 `session_prompt()` 会立即返回已排队消息的 `MessageId`;绕过 `Session.run()` 的调用方必须自行负责后续的活动边界。 diff --git a/python/sdk/pyproject.toml b/python/sdk/pyproject.toml index eeef355e90..48ffbf2499 100644 --- a/python/sdk/pyproject.toml +++ b/python/sdk/pyproject.toml @@ -3,7 +3,7 @@ requires = ["hatchling>=1.30.1"] build-backend = "hatchling.build" [project] -name = "deepseek-harness" +name = "deepseek-harness-sdk" version = "0.0.0.dev0" description = "Python SDK for DeepSeek Harness" readme = "README.md" diff --git a/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index 7e5f660070..c66de01a3e 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -32,13 +32,43 @@ def test_release_tag_must_match_repository_version() -> None: build_python_release.validate_release_tag("python-v1.2.4", "1.2.3") -def test_repository_version_rejects_non_stable_versions(tmp_path: Path) -> None: - (tmp_path / "package.json").write_text('{"version":"1.2.3-dev"}\n') +def test_repository_version_accepts_a_prerelease(tmp_path: Path) -> None: + (tmp_path / "package.json").write_text('{"version":"1.2.3-rc.1"}\n') - with pytest.raises(ValueError, match="must be stable X.Y.Z"): + assert build_python_release.repository_version(tmp_path) == "1.2.3-rc.1" + + +def test_repository_version_rejects_malformed_versions(tmp_path: Path) -> None: + (tmp_path / "package.json").write_text('{"version":"v1.2"}\n') + + with pytest.raises(ValueError, match="must be X.Y.Z"): build_python_release.repository_version(tmp_path) +def test_pep440_version_spells_a_prerelease_the_python_way() -> None: + # Build backends normalize to this spelling, so the wheel filename and + # metadata checks compare against it rather than the repository version. + assert build_python_release.pep440_version("1.2.3") == "1.2.3" + assert build_python_release.pep440_version("1.2.3-rc.1") == "1.2.3rc1" + assert build_python_release.pep440_version("1.2.3-alpha.2") == "1.2.3a2" + assert build_python_release.pep440_version("1.2.3-beta.10") == "1.2.3b10" + + with pytest.raises(ValueError, match="no PEP 440 spelling"): + build_python_release.pep440_version("1.2.3-nightly") + + +def test_stage_sdk_keeps_distribution_module_and_runtime_pin_distinct(tmp_path: Path) -> None: + destination = tmp_path / "staging" + + build_python_release.stage_sdk(destination, "1.2.3") + + pyproject = (destination / "pyproject.toml").read_text() + assert 'name = "deepseek-harness-sdk"' in pyproject + assert 'version = "1.2.3"' in pyproject + assert '"deepseek-harness-runtime-bin==1.2.3"' in pyproject + assert (destination / "src" / "deepseek_harness" / "__init__.py").is_file() + + @pytest.mark.parametrize(("target", "with_helper"), [("linux-x64", False), ("macos-arm64", True)]) def test_stage_runtime_copies_platform_payload( tmp_path: Path, target: str, with_helper: bool diff --git a/python/sdk/uv.lock b/python/sdk/uv.lock index 94219b95ad..e2a62a9fe0 100644 --- a/python/sdk/uv.lock +++ b/python/sdk/uv.lock @@ -21,7 +21,12 @@ wheels = [ ] [[package]] -name = "deepseek-harness" +name = "deepseek-harness-runtime-bin" +version = "0.0.0.dev0" +source = { editable = "../sdk-runtime" } + +[[package]] +name = "deepseek-harness-sdk" version = "0.0.0.dev0" source = { editable = "." } dependencies = [ @@ -43,17 +48,12 @@ requires-dist = [ [package.metadata.requires-dev] test = [{ name = "pytest", specifier = ">=8.0" }] -[[package]] -name = "deepseek-harness-runtime-bin" -version = "0.0.0.dev0" -source = { editable = "../sdk-runtime" } - [[package]] name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions" }, + { name = "typing-extensions", marker = "python_full_version < '3.13'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ diff --git a/scripts/build-exe-for-python-sdk.ts b/scripts/build-exe-for-python-sdk.ts index 536342dc89..a16aa93c97 100644 --- a/scripts/build-exe-for-python-sdk.ts +++ b/scripts/build-exe-for-python-sdk.ts @@ -8,7 +8,7 @@ import { spawn } from 'node:child_process' import { existsSync, statSync } from 'node:fs' -import { chmod, copyFile, mkdir, readFile, rm, writeFile } from 'node:fs/promises' +import { chmod, copyFile, cp, lstat, mkdir, readFile, readdir, realpath, rm, writeFile } from 'node:fs/promises' import { basename, dirname, join, resolve, sep } from 'node:path' import { parseArgs } from 'node:util' @@ -16,8 +16,8 @@ const root = resolve(import.meta.dirname, '..') /** The closure manifest whose dependencies define the executable. */ const DEPLOY_ROOT_PACKAGE = 'dsh-jsonrpc-agent-pkg' -/** The app entry inside the deployed closure. */ -const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/bin.js' +/** The closed-runtime app entry inside the deployed closure. */ +const ENTRY_BIN = 'node_modules/@deepseek-ai/dsh-jsonrpc-demo/lib/packaged-bin.js' const OUTPUT_BASENAME = 'dsh-jsonrpc-agent-pkg' /** Default Node major; SEA mode requires at least Node 22. */ const DEFAULT_NODE_RANGE = 'node24' @@ -28,6 +28,8 @@ const OUT_DIR = 'dist-exe' const PYTHON_RUNTIME_DIR = 'python/sdk-runtime/src/deepseek_harness_runtime/runtime' /** The deployed closure doubles as the node-mode carrier. */ const PYTHON_NODE_SUBDIR = 'node' +/** Legacy deploy may hoist peer-specialized workspace packages back here. */ +const DEPLOY_SOURCE_NODE_MODULES = 'python/sdk-runtime/node_modules' /** Documentation excluded from the generated runtime directory. */ const DEPLOY_ONLY_DOCS = ['README.md', 'README.zh.md', 'README.i18n.yaml'] @@ -256,6 +258,8 @@ class SingleExeBuild { '--config.link-workspace-packages=true', this.staging, ]) + await this.restoreLegacyHoists() + await this.materializeStagedLinks() if (this.cli.dryRun) { for (const name of DEPLOY_ONLY_DOCS) console.log(`build-exe-for-python-sdk: [dry-run] rm -f ${join(this.staging, name)}`) } else { @@ -263,6 +267,94 @@ class SingleExeBuild { } } + /** + * Restore direct packages that pnpm's legacy hoister places beside the deploy + * source instead of in the target. The runtime manifest supplies every peer, + * so package-local node_modules trees are omitted to preserve one flat Cordis + * instance and a symlink-free packaged payload. + */ + private async restoreLegacyHoists(): Promise { + if (this.cli.dryRun) { + console.log('build-exe-for-python-sdk: [dry-run] restore direct dependencies omitted by legacy deploy') + return + } + const manifestPath = join(this.staging, 'package.json') + const manifest = JSON.parse(await readFile(manifestPath, 'utf8')) as { + dependencies?: Record + } + const sourceNodeModules = resolve(root, DEPLOY_SOURCE_NODE_MODULES) + const restored: string[] = [] + for (const dependency of Object.keys(manifest.dependencies ?? {}).sort()) { + const destination = join(this.staging, 'node_modules', dependency) + if (existsSync(destination)) continue + const source = join(sourceNodeModules, dependency) + if (!existsSync(source)) { + throw new Error( + `build-exe-for-python-sdk: deployed dependency ${dependency} is absent from both ${destination} and ${source}.`, + ) + } + await mkdir(dirname(destination), { recursive: true }) + const nestedNodeModules = join(source, 'node_modules') + await cp(source, destination, { + recursive: true, + dereference: true, + filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep), + }) + restored.push(dependency) + } + const stillMissing = Object.keys(manifest.dependencies ?? {}) + .filter(dependency => !existsSync(join(this.staging, 'node_modules', dependency))) + if (stillMissing.length > 0) { + throw new Error(`build-exe-for-python-sdk: staged dependencies remain missing: ${stillMissing.join(', ')}.`) + } + if (restored.length > 0) { + console.log(`build-exe-for-python-sdk: restored legacy deploy hoists: ${restored.join(', ')}`) + } + } + + /** Replace deploy-time package links with files and reject any remaining link. */ + private async materializeStagedLinks(): Promise { + if (this.cli.dryRun) { + console.log('build-exe-for-python-sdk: [dry-run] materialize staged package links') + return + } + const nodeModules = join(this.staging, 'node_modules') + let remaining = await this.findSymlink(nodeModules) + while (remaining !== undefined) { + const segments = remaining.slice(nodeModules.length + 1).split(sep) + const binIndex = segments.lastIndexOf('.bin') + if (binIndex >= 0) { + await rm(join(nodeModules, ...segments.slice(0, binIndex + 1)), { recursive: true, force: true }) + remaining = await this.findSymlink(nodeModules) + continue + } + const destination = remaining + const source = await realpath(destination) + const nestedNodeModules = join(source, 'node_modules') + await rm(destination, { recursive: true, force: true }) + await cp(source, destination, { + recursive: true, + dereference: true, + filter: path => path !== nestedNodeModules && !path.startsWith(nestedNodeModules + sep), + }) + remaining = await this.findSymlink(nodeModules) + } + } + + /** Return the first symbolic link below a directory, if one exists. */ + private async findSymlink(directory: string): Promise { + for (const entry of await readdir(directory, { withFileTypes: true })) { + const path = join(directory, entry.name) + const metadata = await lstat(path) + if (metadata.isSymbolicLink()) return path + if (metadata.isDirectory()) { + const nested = await this.findSymlink(path) + if (nested !== undefined) return nested + } + } + return undefined + } + /** Add the executable entry and pkg assets to the staged manifest. */ async injectPkgConfig(): Promise { const patch = { bin: ENTRY_BIN, pkg: { assets: ASSET_GLOBS } } diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index ec049cdd4f..d326222efa 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -17,6 +17,8 @@ from pathlib import Path ROOT = Path(__file__).resolve().parents[1] +SDK_DISTRIBUTION = "deepseek-harness-sdk" +RUNTIME_DISTRIBUTION = "deepseek-harness-runtime-bin" PLATFORMS = { "linux-x64": ("manylinux_2_28_x86_64", "dsh-jsonrpc-agent-pkg-linux-x64"), "linux-arm64": ("manylinux_2_28_aarch64", "dsh-jsonrpc-agent-pkg-linux-arm64"), @@ -41,6 +43,8 @@ def main() -> None: args = parser.parse_args() version = repository_version() validate_release_tag(args.tag, version) + # Wheels carry the PEP 440 spelling; the tag keeps the repository spelling. + wheel_version = pep440_version(version) if args.package == "runtime" and (args.platform is None or args.runtime_exe is None): parser.error("runtime builds require --platform and --runtime-exe") if args.package == "sdk" and (args.platform is not None or args.runtime_exe is not None): @@ -51,19 +55,19 @@ def main() -> None: with tempfile.TemporaryDirectory(prefix="dsh-python-release-") as temporary: staging = Path(temporary) / args.package if args.package == "sdk": - stage_sdk(staging, version) + stage_sdk(staging, wheel_version) environment = None - expected = output_dir / f"deepseek_harness-{version}-py3-none-any.whl" + expected = output_dir / f"deepseek_harness_sdk-{wheel_version}-py3-none-any.whl" else: platform_tag, executable_name = PLATFORMS[args.platform] - stage_runtime(staging, version, args.runtime_exe.resolve(), executable_name) + stage_runtime(staging, wheel_version, args.runtime_exe.resolve(), executable_name) environment = {"DSH_RUNTIME_PLATFORM_TAG": platform_tag} - expected = output_dir / f"deepseek_harness_runtime_bin-{version}-py3-none-{platform_tag}.whl" + expected = output_dir / f"deepseek_harness_runtime_bin-{wheel_version}-py3-none-{platform_tag}.whl" command = ["uv", "build", "--wheel", "--out-dir", str(output_dir), str(staging)] subprocess.run(command, cwd=ROOT, env=None if environment is None else {**os.environ, **environment}, check=True) if not expected.is_file(): raise RuntimeError(f"build did not produce expected wheel: {expected}") - verify_wheel(expected, args.package, version, None if args.platform is None else PLATFORMS[args.platform]) + verify_wheel(expected, args.package, wheel_version, None if args.platform is None else PLATFORMS[args.platform]) print(expected) @@ -74,13 +78,35 @@ def repository_version(root: Path = ROOT) -> str: except (OSError, json.JSONDecodeError) as error: raise ValueError(f"could not read repository version from {package_json}") from error version = payload.get("version") if isinstance(payload, dict) else None - if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+", version) is None: + if not isinstance(version, str) or re.fullmatch(r"\d+\.\d+\.\d+(?:-[0-9A-Za-z.]+)?", version) is None: raise ValueError( - f"{package_json} version must be stable X.Y.Z, got {version!r}" + f"{package_json} version must be X.Y.Z with an optional prerelease segment, got {version!r}" ) return version +def pep440_version(version: str) -> str: + """The Python spelling of a repository version. + + A release candidate is `0.0.1-rc.1` in the repository and `0.0.1rc1` under + PEP 440. Build backends normalize to the latter, so the wheel filename and + metadata carry it: comparing them against the repository spelling would + reject every prerelease build. + """ + stable, separator, prerelease = version.partition("-") + if not separator: + return stable + match = re.fullmatch(r"(a|b|c|rc|alpha|beta|pre|preview)\.?(\d+)", prerelease) + if match is None: + raise ValueError( + f"prerelease segment {prerelease!r} has no PEP 440 spelling; use rc.N, alpha.N, or beta.N" + ) + identifier = {"alpha": "a", "beta": "b", "c": "rc", "pre": "rc", "preview": "rc"}.get( + match.group(1), match.group(1) + ) + return f"{stable}{identifier}{match.group(2)}" + + def validate_release_tag(tag: str | None, version: str) -> None: if tag is None: return @@ -160,6 +186,11 @@ def verify_wheel( raise RuntimeError(f"{wheel} has wrong WHEEL tags: {wheel_metadata.get_all('Tag')}") if metadata.get("Version") != version: raise RuntimeError(f"{wheel} has version {metadata.get('Version')}, expected {version}") + expected_distribution = SDK_DISTRIBUTION if package == "sdk" else RUNTIME_DISTRIBUTION + if metadata.get("Name") != expected_distribution: + raise RuntimeError( + f"{wheel} has distribution name {metadata.get('Name')}, expected {expected_distribution}" + ) runtime_files = [ name for name in archive.namelist() if "/runtime/dsh-jsonrpc-agent-pkg-" in name ] @@ -177,7 +208,7 @@ def verify_wheel( raise RuntimeError(f"SDK wheel unexpectedly contains runtime executables: {runtime_files}") if package == "sdk": requirements = metadata.get_all("Requires-Dist") or [] - expected_requirement = f"deepseek-harness-runtime-bin=={version}" + expected_requirement = f"{RUNTIME_DISTRIBUTION}=={version}" if expected_requirement not in requirements: raise RuntimeError(f"{wheel} does not pin {expected_requirement}; found {requirements}") diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index c38197c1e3..cfc10fda49 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -21,15 +21,15 @@ const workspaceGlobs = [ { dir: 'apps', depth: 1 }, ] as const const vendoredPackages = new Set([ - 'cordis', - 'cosmokit', - 'schemastery', - '@cordisjs/plugin-loader', - '@cordisjs/plugin-include', - '@cordisjs/plugin-group', - '@cordisjs/plugin-timer', - '@cordisjs/plugin-hmr', - '@cordisjs/plugin-logger-console', + '@deepseek-ai/cordis', + '@deepseek-ai/cosmokit', + '@deepseek-ai/schemastery', + '@deepseek-ai/cordis-plugin-loader', + '@deepseek-ai/cordis-plugin-include', + '@deepseek-ai/cordis-plugin-group', + '@deepseek-ai/cordis-plugin-timer', + '@deepseek-ai/cordis-plugin-hmr', + '@deepseek-ai/cordis-plugin-logger-console', ]) const publicLandlockPackages = new Set([ '@deepseek-ai/node-addon-landlock-run', @@ -41,6 +41,14 @@ const publicationSourceAllowlist: Readonly> = '@deepseek-ai/node-addon-landlock-run': ['src/main.c'], } const repositoryUrl = 'git+https://github.com/deepseek-harness/deepseek-harness.git' +/** + * Source home the published packages point consumers at. It differs from + * {@link repositoryUrl}, which the Landlock packages keep because npm resolves + * their trusted publishing against the repository that runs the workflow. + */ +const publishedRepositoryUrl = 'git+https://github.com/deepseek-ai/deepseek-harness.git' +/** Directories whose packages this repository publishes: one release member each. */ +const releaseMemberDirectory = /^(?:packages\/[^/]+\/[^/]+|apps\/[^/]+|vendor\/[^/]+)$/ const localArtifactDirs = new Set(['node_modules']) const appPackageFiles: Readonly> = { @@ -72,6 +80,8 @@ interface PackageManifest { repository?: { type?: string; url?: string; directory?: string } peerDependencies?: Record devDependencies?: Record + dependencies?: Record + optionalDependencies?: Record } /** One workspace manifest and its repo-relative path. */ @@ -126,9 +136,13 @@ const packageFileExtras: Readonly> = { '@deepseek-ai/dsh-headless': ['cordis.patch.yml'], '@deepseek-ai/dsh-client-ui-theme': ['lib/styles'], '@deepseek-ai/dsh-helper': ['lib/assets'], + // The Python runtime uses a distinct closed-resolution bin; the public CLI + // keeps config-owned bare-package resolution through lib/bin.js. + '@deepseek-ai/dsh-jsonrpc-demo': ['lib/packaged-bin.js'], // The argv-prefix runner entry ships beside the lib as its own bundle; - // sandbox-local resolves it through the package's ./runner export. - '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js'], + // sandbox-local resolves it through the package's ./runner export. tsdown + // also shares its generated FFI code through a hashed runtime chunk. + '@deepseek-ai/dsh-sandbox-windows-acl': ['lib/runner.js', 'lib/types-*.js'], '@deepseek-ai/dsh-skill-badge': ['assets'], '@deepseek-ai/dsh-subprocess-local': ['scripts/ensure-spawn-helper.mjs'], '@deepseek-ai/dsh-scripts': [ @@ -161,6 +175,9 @@ function expectedDshPackageFiles(manifest: PackageManifest): readonly string[] { ...exportDefault(manifest, './loader') === './lib/loader.js' ? ['lib/loader.js'] : [], // web-react's store subpath ships its own bundle (single-entry builds; no shared chunk). ...exportDefault(manifest, './store') === './lib/store/index.js' ? ['lib/store/index.js'] : [], + // A surface bundle's startup row is its own bundle: the Loader imports it + // as a row module, so it cannot ride inside the package entry. + ...exportDefault(manifest, './startup') === './lib/startup.js' ? ['lib/startup.js'] : [], ...extras, // Subpaths whose runtime default is the tsc-emitted tree (lib/types/*.js — // browser-safe source channels rehomed off src so plain Node can import @@ -225,8 +242,8 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { if (manifest.private === true) { errors.push(`${label}: published Landlock package must not set "private": true`) } - if (manifest.publishConfig?.access !== 'public') { - errors.push(`${label}: published Landlock package must set publishConfig.access to "public"`) + if (manifest.publishConfig?.access !== 'restricted') { + errors.push(`${label}: published Landlock package must set publishConfig.access to "restricted"`) } const expectedDirectory = dir if (manifest.repository?.type !== 'git' @@ -234,6 +251,21 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { || manifest.repository.directory !== expectedDirectory) { errors.push(`${label}: published Landlock package repository must use ${repositoryUrl} with directory ${expectedDirectory} for trusted publishing`) } + } else if (releaseMemberDirectory.test(dir)) { + // Release members state that they are publishable: npm refuses a private + // package, the scope is published privately, and the repository field is + // how a consumer of a private package finds its source. + if (manifest.private === true) { + errors.push(`${label}: release member must not set "private": true`) + } + if (manifest.publishConfig?.access !== 'restricted') { + errors.push(`${label}: release member must set publishConfig.access to "restricted"`) + } + if (manifest.repository?.type !== 'git' + || manifest.repository.url !== publishedRepositoryUrl + || manifest.repository.directory !== dir) { + errors.push(`${label}: release member repository must use ${publishedRepositoryUrl} with directory ${dir}`) + } } else if (manifest.private !== true) { errors.push(`${label}: package.json must set "private": true`) } @@ -271,13 +303,13 @@ function checkWorkspace({ dir, manifest }: WorkspaceManifest): string[] { } if (dir.startsWith('packages/') && manifest.name?.startsWith('@deepseek-ai/dsh-')) { - const peer = manifest.peerDependencies?.cordis - const dev = manifest.devDependencies?.cordis + const peer = manifest.peerDependencies?.['@deepseek-ai/cordis'] + const dev = manifest.devDependencies?.['@deepseek-ai/cordis'] - if (!peer) errors.push(`${label}: cordis must be a peerDependency`) - if (!dev) errors.push(`${label}: cordis must also be a devDependency`) + if (!peer) errors.push(`${label}: @deepseek-ai/cordis must be a peerDependency`) + if (!dev) errors.push(`${label}: @deepseek-ai/cordis must also be a devDependency`) if (peer && dev && peer !== dev) { - errors.push(`${label}: cordis peer (${peer}) and dev (${dev}) ranges must match`) + errors.push(`${label}: @deepseek-ai/cordis peer (${peer}) and dev (${dev}) ranges must match`) } if (manifest.version !== repositoryVersion) { errors.push(`${label}: package.json version must match root version ${repositoryVersion ?? '(missing)'}`) @@ -346,13 +378,44 @@ function checkHierarchyShape(): string[] { } function checkRepositoryVersion(): string[] { - if (repositoryVersion && /^\d+\.\d+\.\d+$/.test(repositoryVersion)) return [] - return ['package.json: version must be stable X.Y.Z'] + // The root carries the dsh release family's version, so a prerelease such as + // 0.0.1-rc.1 is a valid state between `release:dsh` and its publication. + if (repositoryVersion && /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(repositoryVersion)) return [] + return ['package.json: version must be X.Y.Z with an optional prerelease segment'] } +/** Dependency sections whose ranges reach a published tarball or a local install. */ +const dependencySections = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'] as const + +/** + * Require the `workspace:` protocol for every reference to a workspace member. + * + * A hand-written range says nothing about the version the workspace actually + * carries, and `pnpm pack` leaves it alone: `^0.0.1` published from version + * `0.0.2` names a version that does not exist. The protocol makes pack + * substitute the member's real version, so no release step rewrites ranges. + * @param manifests - every workspace manifest. + * @returns One error per reference that names a workspace member without the protocol. + */ +function checkWorkspaceProtocol(manifests: readonly WorkspaceManifest[]): string[] { + const members = new Set(manifests.map(entry => entry.manifest.name).filter(name => name !== undefined)) + const errors: string[] = [] + for (const { dir, manifest } of manifests) { + for (const section of dependencySections) { + for (const [name, range] of Object.entries(manifest[section] ?? {})) { + if (!members.has(name) || range.startsWith('workspace:')) continue + errors.push(`${manifest.name ?? dir}: ${section}.${name} must use the workspace: protocol, got ${range}`) + } + } + } + return errors +} + +const manifests = workspaceManifests() const errors = [ ...checkRepositoryVersion(), - ...workspaceManifests().flatMap(checkWorkspace), + ...manifests.flatMap(checkWorkspace), + ...checkWorkspaceProtocol(manifests), ...checkHierarchyShape(), ...collectProjectReferenceFaceViolations(root), ] diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 2269c7cbee..2642f234c4 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -27,46 +27,61 @@ describe('CI workflow', () => { } }) - it('keeps Wine blocking while native Windows reports independently', () => { + it('keeps a required Wine Windows job, a non-blocking native Windows job with failover, and a master-only standby', () => { const workflow = loadWorkflow('.github/workflows/ci.yml') if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs.windows) || !isRecord(workflow.jobs['windows-native']) + || !isRecord(workflow.jobs['wine-apt-cache']) + || !isRecord(workflow.jobs['serial-windows']) || !isRecord(workflow.jobs['all-checks-passed'])) { - throw new TypeError('CI workflow must define Wine, native Windows, and aggregate jobs') + throw new TypeError('CI workflow must define windows, windows-native, wine-apt-cache, serial-windows, and all-checks-passed jobs') } const windows = workflow.jobs.windows const windowsNative = workflow.jobs['windows-native'] + const wineAptCache = workflow.jobs['wine-apt-cache'] + const serialWindows = workflow.jobs['serial-windows'] const aggregate = workflow.jobs['all-checks-passed'] - if (!Array.isArray(windows.steps) || !Array.isArray(windowsNative.steps) || !Array.isArray(aggregate.needs)) { - throw new TypeError('Windows jobs must define steps and the aggregate must define needs') + if (!Array.isArray(windows.steps) || !Array.isArray(aggregate.needs)) { + throw new TypeError('Windows job must define steps and the aggregate must define needs') } - const nativeCommandSteps = windowsNative.steps.filter((step): step is Record & { run: string } => ( + const commandSteps = windows.steps.filter((step): step is Record & { run: string } => ( isRecord(step) && typeof step.run === 'string' )) + // Required PR job: Wine on ubuntu-latest, runs wine-windows-gates.sh. expect(windows['runs-on']).toBe('ubuntu-latest') expect(windows.name).toBe('windows node 24 / wine blocking') expect(windows.if).toBe("github.event_name == 'pull_request'") - expect(JSON.stringify(windows)).toContain('bash scripts/wine-windows-gates.sh') - expect(workflow.jobs).toHaveProperty('wine-apt-cache') - expect(windowsNative['runs-on']).toBe('dsh-windows-2025-16core') + expect(commandSteps.some(step => step.run.includes('wine-windows-gates.sh'))).toBe(true) + + // windows-native: non-blocking native job with failover, runs windows-complete. + expect(typeof windowsNative['runs-on']).toBe('string') + expect(windowsNative['runs-on']).toContain('DSH_CI_FAILOVER') + expect(windowsNative['runs-on']).toContain('self-hosted') + expect(windowsNative['runs-on']).toContain('dsh-win-ci') + expect(windowsNative['runs-on']).toContain('dsh-windows-2025-16core') expect(windowsNative.name).toBe('windows node 24 / native complete') - expect(windowsNative['timeout-minutes']).toBe(60) expect(windowsNative.if).toBe("github.event_name == 'pull_request'") - expect(windowsNative.env).toMatchObject({ - DSH_COVERAGE_MAX_WORKERS: '2', - DSH_GATE_CONCURRENCY: '2', - DSH_PUBLINT_CONCURRENCY: '8', - }) - expect(windowsNative).not.toHaveProperty('continue-on-error') - expect(nativeCommandSteps).toHaveLength(3) - expect(nativeCommandSteps.every(step => step.shell === 'pwsh')).toBe(true) + const nativeCommandSteps = (windowsNative.steps as unknown[]).filter((step): step is Record & { run: string } => ( + isRecord(step) && typeof step.run === 'string' + )) expect(nativeCommandSteps.map(step => step.run)).toContain('pnpm run check:ci:windows-complete') - expect(JSON.stringify(windowsNative)).not.toMatch(/wine/i) + + // wine-apt-cache: master-only, seeds the Wine apt cache. + expect(wineAptCache.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") + expect(wineAptCache['runs-on']).toBe('ubuntu-latest') + + // serial-windows: master-only standby, self-hosted, non-blocking. + expect(serialWindows.if).toBe("github.event_name == 'push' && github.ref == 'refs/heads/master'") + expect(serialWindows['runs-on']).toEqual(['self-hosted', 'dsh-win-ci', 'windows']) + expect(serialWindows.name).toBe('serial / windows (self-hosted standby)') + + // Aggregate: Wine `windows` required, native `windows-native` excluded. expect(aggregate.needs).toContain('windows') expect(aggregate.needs).not.toContain('windows-native') + expect(aggregate.needs).not.toContain('serial-windows') }) it('keeps supported LSP source under native Windows coverage', () => { @@ -113,20 +128,42 @@ describe('E2B e2e workflow', () => { }) describe('Issue lifecycle workflow', () => { - it('uses review signals instead of rerunning when a draft becomes ready', () => { + it('uses explicit review handoff events without rerunning when a draft becomes ready', () => { const lifecycle = loadWorkflow('.github/workflows/issue-lifecycle.yml') const lifecyclePullRequest = workflowEvent(lifecycle, 'pull_request') const lifecycleReview = workflowEvent(lifecycle, 'pull_request_review') + const lifecycleJob = workflowJob(lifecycle, 'lifecycle') const policy = loadWorkflow('.github/workflows/issue-policy.yml') const policyPullRequest = workflowEvent(policy, 'pull_request') expect(lifecyclePullRequest.types).not.toContain('ready_for_review') expect(lifecyclePullRequest.types).toContain('review_requested') - expect(lifecycleReview.types).toContain('submitted') + expect(lifecycleReview.types).toEqual(['submitted']) + expect(lifecycleJob.if).toBe( + "${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}", + ) expect(policyPullRequest.types).toContain('ready_for_review') }) }) +describe('Git hooks', () => { + it('leaves frozen Agent Note sidecars to the archive verifier', () => { + const lefthook = loadWorkflow('lefthook.yml') + + for (const hookName of ['pre-commit', 'pre-merge-commit']) { + const hook = lefthook[hookName] + if (!isRecord(hook) || !Array.isArray(hook.jobs)) { + throw new TypeError(`lefthook must define ${hookName} jobs`) + } + const pairing: unknown = hook.jobs.find( + (job: unknown) => isRecord(job) && job.name === 'translation pairing (staged records)', + ) + + expect(pairing).toMatchObject({ exclude: ['.agents/notes/archived/**'] }) + } + }) +}) + function loadWorkflow(path: string): Record { const workflow: unknown = yaml.load(readFileSync(resolve(root, path), 'utf8')) if (!isRecord(workflow)) throw new TypeError(`${path} must define a workflow`) @@ -140,6 +177,13 @@ function workflowEvent(workflow: Record, event: string): Record return workflow.on[event] } +function workflowJob(workflow: Record, job: string): Record { + if (!isRecord(workflow.jobs) || !isRecord(workflow.jobs[job])) { + throw new TypeError(`workflow must define the ${job} job`) + } + return workflow.jobs[job] +} + function isRecord(value: unknown): value is Record { return typeof value === 'object' && value !== null && !Array.isArray(value) } diff --git a/scripts/cordis-walk.ts b/scripts/cordis-walk.ts index befc6156e1..3a38f0ca29 100644 --- a/scripts/cordis-walk.ts +++ b/scripts/cordis-walk.ts @@ -11,12 +11,12 @@ import ts from 'typescript' /** Cheap textual prefilter for a cordis module merge, quote-style agnostic * (the AST match below reads `stmt.name.text` and never sees the quotes). */ -const MERGE_HEAD = /declare module ['"](?:cordis|\.\/context\.ts)['"]/ +const MERGE_HEAD = /declare module ['"](?:@deepseek-ai\/cordis|\.\/context\.ts)['"]/ /** * Parse every file matching `patterns` (repo-relative, sorted, `/`-normalized) * that textually contains a cordis module merge, yielding one entry per merge - * BLOCK — a file may legally hold several `declare module 'cordis'` blocks + * BLOCK — a file may legally hold several `declare module '@deepseek-ai/cordis'` blocks * (the Typert analyzer reads them all), so the exhaustiveness scan must too. * Files without a merge are skipped. * @param scanRoot - Repository root the patterns are resolved against. @@ -39,14 +39,14 @@ export function contextMergeFiles( return out } -/** Every cordis module-merge body in `sf`: `declare module 'cordis'` (harness +/** Every cordis module-merge body in `sf`: `declare module '@deepseek-ai/cordis'` (harness * packages) or `declare module './context.ts'` (vendor core), in source order. * Module-local: consumers walk blocks through {@link contextMergeFiles}. */ function cordisModuleBodies(sf: ts.SourceFile): ts.ModuleBlock[] { const bodies: ts.ModuleBlock[] = [] for (const stmt of sf.statements) { if (!ts.isModuleDeclaration(stmt) || !ts.isStringLiteral(stmt.name)) continue - if (stmt.name.text !== 'cordis' && stmt.name.text !== './context.ts') continue + if (stmt.name.text !== '@deepseek-ai/cordis' && stmt.name.text !== './context.ts') continue if (stmt.body && ts.isModuleBlock(stmt.body)) bodies.push(stmt.body) } return bodies @@ -60,7 +60,7 @@ export function cordisModuleBody(sf: ts.SourceFile): ts.ModuleBlock | null { } /** - * Every `key: Type` property a `declare module 'cordis'` Context merge + * Every `key: Type` property a `declare module '@deepseek-ai/cordis'` Context merge * declares in one module body. * @param body - The cordis module augmentation block. * @param sf - Owning source file (for text extraction). @@ -79,7 +79,7 @@ export function contextKeyMap(body: ts.ModuleBlock, sf: ts.SourceFile): Map { const dir = join(root, 'packages/client/ui-x/src/client') mkdirSync(dir, { recursive: true }) writeFileSync(join(dir, 'index.ts'), [ - "declare module 'cordis' {", + "declare module '@deepseek-ai/cordis' {", ' interface Events {', " 'x/changed'(): void", ' }', @@ -153,12 +153,12 @@ describe('cordis-walk scan reach', () => { // backstop must not stop at the first one, skip the double-quoted legal // form, or ignore .tsx sources. writeFileSync(join(dir, 'split.ts'), [ - "declare module 'cordis' {", + "declare module '@deepseek-ai/cordis' {", ' interface Context {', ' first: FirstService', ' }', '}', - 'declare module "cordis" {', + 'declare module "@deepseek-ai/cordis" {', ' interface Events {', " 'second/changed'(): void", ' }', @@ -167,7 +167,7 @@ describe('cordis-walk scan reach', () => { '', ].join('\n')) writeFileSync(join(dir, 'view.tsx'), [ - "declare module 'cordis' {", + "declare module '@deepseek-ai/cordis' {", ' interface Context {', ' fromTsx: TsxService', ' }', @@ -189,7 +189,7 @@ describe('cordis-walk scan reach', () => { it('reads string-literal and identifier member names from an Events merge', () => { const sf = ts.createSourceFile('x.ts', [ - "declare module 'cordis' {", + "declare module '@deepseek-ai/cordis' {", ' interface Events {', " 'scope/list'(items: string[]): void", ' plain(): void', diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 885e0cf9d2..8fe9de649c 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -63,6 +63,7 @@ export const SERVICE_PAGE: Record = { httpServer: 'http-server.md', invariants: 'invariants.md', llm: 'llm-streaming.md', + messageFeedback: 'feedback.md', permission: 'permission.md', planMode: 'plan.md', pty: 'pty.md', @@ -99,7 +100,7 @@ export const SERVICE_PAGE: Record = { /** * Context keys declared in `interface Context` merges that the rendering * projection cannot see, each with the reason and its documentation owner. - * The scan that enforces this list reads EVERY `declare module 'cordis'` + * The scan that enforces this list reads EVERY `declare module '@deepseek-ai/cordis'` * Context merge under `packages/x/x/src/**` — any depth, not only root * `index.ts` files with a same-named service class — so a new service can * never silently join this blind spot: it either enters {@link SERVICE_PAGE} @@ -111,6 +112,8 @@ export const SERVICE_PAGE: Record = { */ export const SERVICE_WALK_EXEMPTIONS: Record = { agent: 'not a service: the DX accessor field on Agent.ctx (root accessor defaulting to undefined) — docs/subsystems/core.md owns the Agent handle', + appExit: 'not a service: launcher-provided bounded process-exit callback — packages/boot/cmdline/README.md owns the launcher contract', + cmdlineArgs: 'not a service: launcher-provided immutable app argument accessor — packages/boot/cmdline/README.md owns the launcher contract', configuredAgentIdentities: 'not a service: launcher-provided boot-context value (ConfiguredAgentIdentities | undefined) — packages/core/agent-loop/README.md owns this launcher contract', launcherSessionQueryPath: 'not a service: launcher-provided boot-context value (string | undefined) — packages/session-query/session-query-sqlite/README.md owns this launcher contract', dshHomePath: 'not a service: boot-provided root accessor function (typeof dshHomePath | undefined) for Loader !!js config expressions — packages/boot/app-boot/README.md owns the boot contract', @@ -130,7 +133,6 @@ export const SERVICE_WALK_EXEMPTIONS: Record = { models: 'client-side interface-typed browser service — packages/client/ui-model/README.md owns the surface', modules: 'client-side interface-typed browser service — packages/client/modules/README.md owns the surface', remote: 'client-side interface-typed gateway accessor (ClientRemote) — packages/api/gateway/README.md owns the surface', - sessionHistory: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface', slash: 'client-side interface-typed browser service — packages/client/ui-slash/README.md owns the surface', slots: 'client-side interface-typed browser service — packages/client/runtime/README.md owns the surface', theme: 'client-side interface-typed browser service — packages/client/ui-theme/README.md owns the surface', @@ -168,7 +170,7 @@ export const EVENT_SCOPE_PAGE: Record = { * Event names declared in `interface Events` merges that the rendering * projection cannot see, each with the reason and its documentation owner. * The mirror of {@link SERVICE_WALK_EXEMPTIONS} for events: an independent - * scan reads EVERY `declare module 'cordis'` Events merge under + * scan reads EVERY `declare module '@deepseek-ai/cordis'` Events merge under * `packages/x/x/src/**`, so a declared event either renders onto a subsystems * page (via {@link EVENT_SCOPE_PAGE}) or names itself here — never vanishes * silently. Keys are full event names, not scopes: client-face events share @@ -228,6 +230,25 @@ export const LINK_MAP: Readonly> = { ResolvedRetryPolicy: 'llm-streaming.md', Message: 'llm-streaming.md', MessageSource: 'llm-streaming.md', + MessageFeedbackDeleteRequest: 'feedback.md', + MessageFeedbackDeleteResult: 'feedback.md', + MessageFeedbackDeleteValue: 'feedback.md', + MessageFeedbackFailure: 'feedback.md', + MessageFeedbackItem: 'feedback.md', + MessageFeedbackListRequest: 'feedback.md', + MessageFeedbackListResult: 'feedback.md', + MessageFeedbackListValue: 'feedback.md', + MessageFeedbackNoteBlank: 'feedback.md', + MessageFeedbackNoteTooLarge: 'feedback.md', + MessageFeedbackPutRequest: 'feedback.md', + MessageFeedbackPutResult: 'feedback.md', + MessageFeedbackRating: 'feedback.md', + MessageFeedbackRejected: 'feedback.md', + MessageFeedbackSessionNotFound: 'feedback.md', + MessageFeedbackSuccess: 'feedback.md', + MessageFeedbackTargetNotFound: 'feedback.md', + MessageFeedbackVersion: 'feedback.md', + MessageFeedbackVersionConflict: 'feedback.md', UserMessage: 'session.md', PreStepDecision: 'core.md', PreStepContext: 'core.md', @@ -301,6 +322,7 @@ export const LINK_MAP: Readonly> = { SessionLocation: 'persistence.md', SessionPreparation: 'persistence.md', SessionPersistenceSnapshot: 'persistence.md', + SessionRawArtifact: 'persistence.md', ConfinedArgv: 'sandbox.md', SandboxExecutionPolicy: 'sandbox.md', SandboxMode: 'sandbox.md', @@ -383,6 +405,7 @@ export const LINK_MAP: Readonly> = { TaskRead: 'tasks.md', TaskSnapshot: 'tasks.md', TaskStart: 'tasks.md', + TasksChangedListener: 'tasks.md', TokenMeasurement: 'token-meter.md', CodeDispatchLog: 'tools.md', PostToolDecision: 'tools.md', @@ -461,6 +484,7 @@ export const FOUNDATION_TYPE_NAMES: ReadonlySet = new Set([ 'Promise', 'Record', 'Readonly', + 'Uint8Array', ]) /** Project types deliberately documented outside the subsystems catalog. */ diff --git a/scripts/gen-doc-graphs.ts b/scripts/gen-doc-graphs.ts index 699bcae707..5b950782b1 100644 --- a/scripts/gen-doc-graphs.ts +++ b/scripts/gen-doc-graphs.ts @@ -135,7 +135,7 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'session', title: 'In-memory session store', mode: 'core', - consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants'], + consumers: ['agent-loop', 'agent', 'session-persistence', 'session-query', 'session-query-sqlite', 'subagent-inprocess', 'invariants', 'message-feedback'], note: 'Owns append-only Session instances and emits the durable session event feed.', }, { @@ -167,7 +167,7 @@ const SERVICE_ROLES: ServiceRole[] = [ title: 'Durable session persistence seam', mode: 'seam', implementations: ['session-persistence-jsonl', 'session-persistence-sqlite'], - consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite'], + consumers: ['agent-loop', 'tool-bash', 'hooks-claude', 'hooks-codex', 'session-query', 'session-query-sqlite', 'message-feedback'], note: 'Backends persist the same SessionEvent vocabulary; apps choose a backend at composition time.', }, { @@ -211,9 +211,16 @@ const SERVICE_ROLES: ServiceRole[] = [ pkg: 'storage-domain', title: 'Domain data facility', mode: 'core', - consumers: ['workspace'], + consumers: ['workspace', 'message-feedback'], note: 'Waits for every configured backend, then publishes the domain form as one lifecycle-bound service for typed durable state.', }, + { + key: 'messageFeedback', + pkg: 'message-feedback', + title: 'Lifecycle-bound message feedback', + mode: 'core', + note: 'Owns local per-assistant-message feedback, lifecycle and target validation, per-item compare-and-set, and the Host unary Remote contract without entering Session history or telemetry.', + }, { key: 'workspace', pkg: 'workspace', diff --git a/scripts/gen-persistence-catalog.ts b/scripts/gen-persistence-catalog.ts index 173d4222cb..debc165eab 100644 --- a/scripts/gen-persistence-catalog.ts +++ b/scripts/gen-persistence-catalog.ts @@ -13,6 +13,7 @@ import { parseJsDoc, pointer, rawJsDoc, reportViolations } from './jsdoc.ts' const root = resolve(import.meta.dirname, '..') const OUT = 'docs/persistence-catalog.md' +const OUT_RUNTIME_TYPES = 'packages/core/session/src/known-event-types.ts' /** The fenced-block info string for generated declaration blocks (skipped by * doc-typecheck, since their imported types are not standalone-compilable). */ @@ -359,7 +360,7 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv '', 'This file is GENERATED from source (`scripts/gen-persistence-catalog.ts`) and verified fresh by `pnpm run verify-persistence-catalog` (part of `doc-sync`) — do not edit it by hand. Declaration blocks retain the source declaration and nested property JSDoc, removing only the indentation imposed by a containing interface/module, and use a `ts persistence-catalog` fence (skipped by doc-typecheck because declarations reference types from their owning modules). Type names in a payload link to the page that documents them. See [the persistence-log-catalog Agent Note](../.agents/notes/archived/process/2026-07-04-persistence-log-catalog.md).', '', - 'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.', + 'The envelope declarations below compose each event\'s `type`, monotonic `seq`, epoch-ms `time`, `data`, the optional `ignorable` unknown-type skip marker, and the conditional `surfaceOp`/`sourceEventSeqs` fields. **surface** marks a `SurfaceEventType` member: it produces an LLM message and declares how it joins the surface list. **log-only** marks everything else: a durable, replayable record with no derived-history contribution. Every payload is JSON-serializable (enforced at `Session.append`), and the whole format is pinned at `SESSION_FORMAT_VERSION = 0` — pre-release, no compatibility implied ([the version stance](subsystems/persistence.md)). Scope: the packages in this repo; a downstream plugin can merge further event types, which are outside this catalog by construction.', '', '## Event envelope', '', @@ -382,31 +383,79 @@ export function render(events: AnnotatedLogEventEntry[], envelopeTypes: EventEnv return lines.join('\n') } -/** CLI entry: default writes the catalog, `--check` fails if the committed copy +/** + * Render the runtime known-vocabulary module: every event type the packages in + * this repo can write, as a generated `ReadonlySet` the read path checks + * unknown-type refusal against (`SessionEvent.ignorable` contract). + */ +export function renderKnownEventTypes(events: AnnotatedLogEventEntry[]): string { + const names = [...new Set(events.map(e => e.name))].sort() + return [ + '/**', + ' * GENERATED by `scripts/gen-persistence-catalog.ts` — do not edit by hand; run', + ' * `pnpm run gen-persistence-catalog` to regenerate (verified fresh by', + ' * `pnpm run verify-persistence-catalog`, part of `doc-sync`).', + ' * @module @deepseek-ai/dsh-session/known-event-types', + ' */', + '', + '/**', + ' * Every `SessionEventMap` member declared in this repository — the event', + ' * vocabulary this build understands. The persistence read path refuses to', + ' * interpret a log containing a type outside this set unless the event', + ' * carries the envelope\'s `ignorable` marker (see `SessionEvent.ignorable`', + ' * in `./types.ts`): such a log was likely written by a newer harness, and', + ' * silently skipping a required event would reconstruct a wrong session.', + ' * Downstream (out-of-repo) plugin events are outside this list by', + ' * construction; a registration surface for them is deferred until such a', + ' * consumer exists.', + ' */', + 'export const KNOWN_SESSION_EVENT_TYPES: ReadonlySet = new Set([', + ...names.map(name => ` '${name}',`), + '])', + '', + ].join('\n') +} + +/** One generated artifact: repo-relative target and its freshly-rendered content. */ +interface GeneratedArtifact { + readonly out: string + readonly content: string +} + +/** CLI entry: default writes the artifacts, `--check` fails if a committed copy * is stale. Guarded behind an entry-point check so importing this module for - * tests neither regenerates the committed file nor calls process.exit. */ + * tests neither regenerates the committed files nor calls process.exit. */ function main(): void { - const content = render(annotateSurface(collectLogEvents(), collectSurfaceEventTypes()), collectEventEnvelopeTypes()) + const events = annotateSurface(collectLogEvents(), collectSurfaceEventTypes()) + const artifacts: GeneratedArtifact[] = [ + { out: OUT, content: render(events, collectEventEnvelopeTypes()) }, + { out: OUT_RUNTIME_TYPES, content: renderKnownEventTypes(events) }, + ] if (process.argv.includes('--check')) { - let committed: string | null = null - try { - committed = readFileSync(resolve(root, OUT), 'utf8') - } catch { - // Only ENOENT (not yet generated) is expected; a present-but-unreadable - // file is not a state this repo produces. Either way the remedy is the - // same — regenerate — so treat a read failure as "stale". - committed = null - } - if (committed === content) { - console.log(`gen-persistence-catalog: ${OUT} is up to date.`) + const stale = artifacts.filter((artifact) => { + let committed: string | null = null + try { + committed = readFileSync(resolve(root, artifact.out), 'utf8') + } catch { + // Only ENOENT (not yet generated) is expected; a present-but-unreadable + // file is not a state this repo produces. Either way the remedy is the + // same — regenerate — so treat a read failure as "stale". + committed = null + } + return committed !== artifact.content + }) + if (stale.length === 0) { + console.log(`gen-persistence-catalog: ${artifacts.map(a => a.out).join(', ')} are up to date.`) process.exit(0) } - console.error(`gen-persistence-catalog: ${OUT} is stale. Run \`pnpm run gen-persistence-catalog\` and commit ${OUT}.`) + console.error(`gen-persistence-catalog: ${stale.map(a => a.out).join(', ')} stale. Run \`pnpm run gen-persistence-catalog\` and commit the result.`) process.exit(1) } - writeFileSync(resolve(root, OUT), content) - console.log(`gen-persistence-catalog: wrote ${OUT}.`) + for (const artifact of artifacts) { + writeFileSync(resolve(root, artifact.out), artifact.content) + console.log(`gen-persistence-catalog: wrote ${artifact.out}.`) + } } // Run only when invoked as a script, not when imported by a test. diff --git a/scripts/gen-scoped-events.ts b/scripts/gen-scoped-events.ts index ead001a79b..9d070def72 100644 --- a/scripts/gen-scoped-events.ts +++ b/scripts/gen-scoped-events.ts @@ -309,14 +309,14 @@ class ScopedEventGenerator { } } -/** Return whether an Events interface is inside declare module 'cordis'. */ +/** Return whether an Events interface is inside declare module '@deepseek-ai/cordis'. */ function isCordisModuleInterface(node: ts.InterfaceDeclaration): boolean { const block = node.parent const declaration = block.parent return ts.isModuleBlock(block) && ts.isModuleDeclaration(declaration) && ts.isStringLiteral(declaration.name) - && declaration.name.text === 'cordis' + && declaration.name.text === '@deepseek-ai/cordis' } /** Return whether a parameter is the explicit TypeScript this receiver. */ diff --git a/scripts/gen-third-party-notices.spec.ts b/scripts/gen-third-party-notices.spec.ts index aa3198057b..4063219723 100644 --- a/scripts/gen-third-party-notices.spec.ts +++ b/scripts/gen-third-party-notices.spec.ts @@ -134,13 +134,17 @@ describe('parseVendoredRows', () => { const rows = parseVendoredRows(readFileSync(resolve(root, 'vendor/README.md'), 'utf8')) expect(rows.length).toBeGreaterThan(0) - expect(rows).toContainEqual({ npmName: 'cordis', upstream: 'https://github.com/cordiverse/cordis' }) + expect(rows).toContainEqual({ + npmName: '@deepseek-ai/cordis', + upstreamName: 'cordis', + upstream: 'https://github.com/cordiverse/cordis', + }) // The upstream column carries a trailing package path for some rows; it is not part of the URL. expect(rows.every(row => /^https:\/\/\S+$/.test(row.upstream))).toBe(true) }) it('yields nothing when the table columns change, so the generator fails loud', () => { - expect(parseVendoredRows('| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([]) + expect(parseVendoredRows('| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\n')).toEqual([]) }) it('covers every vendored directory, so no package can drop out of the notices', () => { @@ -227,7 +231,7 @@ describe('collectPythonDependencies', () => { it('excludes normalized local project names without exempting a third-party prefix', () => { const pyprojects = [ '[project]\nname = "deepseek-harness-runtime-bin"\ndependencies = ["pydantic"]\n', - '[project]\nname = "deepseek-harness"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n', + '[project]\nname = "deepseek-harness-sdk"\ndependencies = ["DeepSeek.Harness_Runtime-Bin", "deepseek-unrelated"]\n', ] expect(() => collectPythonDependencies(pyprojects)).toThrow( 'python dependency deepseek-unrelated is missing from PYTHON_METADATA', diff --git a/scripts/gen-third-party-notices.ts b/scripts/gen-third-party-notices.ts index c2ab21688a..fb6838de54 100644 --- a/scripts/gen-third-party-notices.ts +++ b/scripts/gen-third-party-notices.ts @@ -27,8 +27,8 @@ const ALL_KINDS = ['dependencies', 'devDependencies', 'optionalDependencies', 'p * root manifest), test infrastructure, the documentation site, the runnable * demo leaves, and the native launcher's build workspace. A runtime * declaration by anything outside these areas is a disclosure-relevant - * runtime dependency, because `scripts/install.sh` installs the repository - * itself and any plugin package can be mounted from a user's `cordis.yml`. + * runtime dependency because any plugin package can be mounted from a user's + * `cordis.yml`. */ const DEV_ONLY_AREAS = [ 'package.json', @@ -83,7 +83,7 @@ const OVERRIDES: Record = { * the generator fails when a manifest names a package this map misses. */ const PYTHON_METADATA: Record = { - pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness`' }, + pydantic: { license: 'MIT', repo: 'https://github.com/pydantic/pydantic', role: 'runtime dependency of `deepseek-harness-sdk`' }, hatchling: { license: 'MIT', repo: 'https://github.com/pypa/hatch', role: 'build backend' }, pytest: { license: 'MIT', repo: 'https://github.com/pytest-dev/pytest', role: 'test-only' }, } @@ -369,7 +369,7 @@ function collectNpmDeps(): ExternalDep[] { */ export function tierExternalDeps(manifests: Map, names: Set): Map { const tiers = new Map() - // `tsx` is runtime by fiat: `bin/dsh` execs the CLI through its ESM hook. + // `tsx` is runtime by fiat: the root source-run scripts execute through its ESM hook. tiers.set('tsx', true) for (const [path, manifest] of manifests) { const devOnly = DEV_ONLY_AREAS.some(area => (area.endsWith('/') ? path.startsWith(area) : path === area)) @@ -387,6 +387,8 @@ export function tierExternalDeps(manifests: Map, names: Set `| \`${row.npmName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')} +| Package | Upstream name | Upstream | License | +| --- | --- | --- | --- | +${vendored.map(row => `| \`${row.npmName}\` | \`${row.upstreamName}\` | [${row.upstream.replace('https://', '')}](${row.upstream}) | MIT |`).join('\n')} ## Runtime npm dependencies -External packages that a workspace package resolves at runtime. \`scripts/install.sh\` installs this repository itself, so the tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default. +External packages that a workspace package resolves at runtime. The tier covers every plugin a user can mount from \`cordis.yml\` — not only what the \`dsh\` CLI, Web UI, and Python SDK runtime load by default. ${renderNpmTable(runtimeDeps)} diff --git a/scripts/gen-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 942a9ec9e0..76878426fe 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -8,7 +8,7 @@ import { globSync, readFileSync, writeFileSync } from 'node:fs' import { basename, resolve } from 'node:path' -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { ToolSchema } from '@deepseek-ai/dsh-llm' import AgentRegistry from '@deepseek-ai/dsh-agent' import type { Agent } from '@deepseek-ai/dsh-agent' @@ -24,6 +24,8 @@ import * as BashEnvPlugin from '@deepseek-ai/dsh-bash-env' import { PwshLocalExecutor } from '@deepseek-ai/dsh-pwsh-local' import LocalSubprocessService from '@deepseek-ai/dsh-subprocess-local' import LocalFileSystem from '@deepseek-ai/dsh-fs-local' +import { AttachmentStore } from '@deepseek-ai/dsh-attachment' +import type { ImageAttachmentLimits, ImageAttachmentRef, SaveImageAttachment, StoredImageAttachment } from '@deepseek-ai/dsh-attachment' import UserInteractionService from '@deepseek-ai/dsh-user-interaction' import PlanModeService from '@deepseek-ai/dsh-plan-mode' import WebService from '@deepseek-ai/dsh-web' @@ -60,6 +62,29 @@ import VmWorkflowEngine from '@deepseek-ai/dsh-workflow-workerthread' import * as ToolRalph from '@deepseek-ai/dsh-tool-ralph' import * as ToolWorkflow from '@deepseek-ai/dsh-tool-workflow' +/** Attachment seam marker that makes the attachments-conditional `read_image` schema harvestable. */ +class CatalogAttachmentStore extends AttachmentStore { + readonly imageLimits: ImageAttachmentLimits = Object.freeze({ + maxImageBytes: 1, + maxImagesPerMessage: 1, + maxMessageImageBytes: 1, + maxImagePixels: 1, + mediaTypes: Object.freeze(['image/png'] as const), + }) + + override validateImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('gen-tool-catalog: attachment validation is unreachable during schema harvest')) + } + + override saveImage(_input: SaveImageAttachment): Promise { + return Promise.reject(new Error('gen-tool-catalog: attachment writes are unreachable during schema harvest')) + } + + override readImage(_ref: ImageAttachmentRef): Promise { + return Promise.reject(new Error('gen-tool-catalog: attachment reads are unreachable during schema harvest')) + } +} + const root = resolve(import.meta.dirname, '..') const OUT = 'docs/tool-catalog.md' @@ -265,16 +290,18 @@ const TOOL_PACKAGES: ToolPackage[] = [ pkg: '@deepseek-ai/dsh-tool-fs', dir: 'tool-fs', source: 'packages/fs/tool-fs/src/index.ts', - requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt'], - writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful mutation', 'tool/result'], + requires: ['ctx.tools', 'ctx.fs', 'ctx.systemPrompt', 'ctx.attachments (read_image registration)', 'ctx.llm + an image-capable route (read_image execution)'], + writes: ['tool/call', 'fs/write-intent or fs/edit-intent for mutations', 'fs/observed after read presence/absence or successful file operation', 'durable attachment (read_image)', 'tool/result'], async mount(ctx) { // The tool needs `fs`; the bare provider is sufficient because policy - // changes behavior, not schema shape. + // changes behavior, not schema shape. The catalog seam marker opts into + // the attachments-conditional read_image schema without attachment I/O. await ctx.plugin(LocalFileSystem) + await ctx.plugin(CatalogAttachmentStore) await ctx.plugin(ToolFs) }, note: - 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. The tool schemas above are identical with or without the policy plugin.', + 'The read-before-write/edit policy is added by `@deepseek-ai/dsh-fs-policy` (an `fs/*` event-gate plugin, no schema change); a deployment that loads these tools is expected to also load it. `read_image` is not registered without `ctx.attachments`; its schema is route-independent, and execution refuses unless the exact routed model declares image input.', }, { pkg: '@deepseek-ai/dsh-tool-fs-search', diff --git a/scripts/install.sh b/scripts/install.sh deleted file mode 100755 index f290782892..0000000000 --- a/scripts/install.sh +++ /dev/null @@ -1,425 +0,0 @@ -#!/bin/sh -# dsh one-line installer. -# -# curl -fsSL https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/master/scripts/install.sh | sh -# -# It clones the harness under ~/.dsh/source (the master clone at -# ~/.dsh/source/master), adds a per-install staging worktree at -# ~/.dsh/source/staging- on branch dsh-staging/, checks -# host dependencies (git, Node, pnpm) and offers to install a missing pnpm, runs -# `pnpm install`, points the stable `~/.dsh/source/current` symlink -# at that staging worktree and symlinks `dsh` onto PATH at `current/bin/dsh`, -# records your API credentials in the Harness home (`~/.dsh`) dsh reads at boot, -# builds the repository artifacts, and launches the Web UI. Keeping every -# checkout under ~/.dsh/source keeps successive -# upgrades in one place instead of scattered sibling clones, and lets staging -# worktrees share the master clone's object store. The PATH symlink resolves through -# `current`, so an upgrade repoints one stable symlink instead of relinking PATH: -# the `dsh` on PATH never moves and can never dangle. -# -# When run from inside an existing checkout (e.g. `sh scripts/install.sh` rather -# than `curl ... | sh`) it never clones and never touches that working tree; -# DSH_REF is ignored. Instead it *adopts* the checkout: `git rev-parse -# --git-common-dir` resolves the repository behind it (for a linked worktree that -# is the real clone, not the worktree), and a fresh staging worktree branched -# from the checkout's HEAD lands in the source container beside `current`. The -# container owns staging worktrees and `current`; the clone is discovered, not -# owned, so an arbitrary clone (~/src/dsh) and a managed one converge on one -# layout and stay upgradable. Adoption carries committed work only: the staging -# worktree branches from HEAD, so uncommitted changes stay in the checkout. -# Setting DSH_SOURCE to a different directory opts back into the normal -# clone/worktree path. -# -# Adopting an arbitrary clone leaves the container not self-contained: its -# staging worktrees hold an absolute gitdir pointer into that clone, so deleting -# it breaks them. `git worktree list` in that clone is the record of which -# worktrees depend on it. -# -# When run through `curl | sh` the script text arrives on stdin, so every -# prompt and the final launch read the controlling terminal (/dev/tty) directly; -# with no terminal the script prints the manual next steps instead. -# -# Overridable via environment: -# DSH_REF branch or tag to clone/checkout (default: master) -# DSH_REPO clone URL (default: the GitHub repo) -# DSH_SOURCE source container directory (default: ~/.dsh/source) -# DSH_MASTER master clone directory (default: $DSH_SOURCE/master) -# DSH_CURRENT stable symlink to the active worktree (default: $DSH_SOURCE/current) -# DSH_BIN_DIR directory the `dsh` symlink lands in (default: ~/.local/bin) -# DSH_HOME Harness home holding profiles and user patches (default: ~/.dsh) -set -eu - -DSH_REF=${DSH_REF:-master} -DSH_REPO=${DSH_REPO:-https://github.com/deepseek-ai/deepseek-harness-sdk.git} -# DSH_SOURCE is the staging-worktree container and the default home of `current`. -# DSH_MASTER names the main clone: clone mode defaults it inside DSH_SOURCE, -# while adoption discovers an existing clone anywhere on disk. Remember whether -# DSH_SOURCE was explicit so a different path selects clone mode. -if [ -n "${DSH_SOURCE:-}" ]; then DSH_SOURCE_EXPLICIT=1; else DSH_SOURCE_EXPLICIT=0; fi -DSH_SOURCE=${DSH_SOURCE:-$HOME/.dsh/source} -DSH_MASTER=${DSH_MASTER:-$DSH_SOURCE/master} -# The stable symlink the PATH launcher resolves through: PATH/dsh -> -# current/bin/dsh -> /bin/dsh. Installs and upgrades repoint `current`; -# the PATH target remains current/bin/dsh. -DSH_CURRENT=${DSH_CURRENT:-$DSH_SOURCE/current} -DSH_BIN_DIR=${DSH_BIN_DIR:-$HOME/.local/bin} -# One UTC basic timestamp names this install's staging branch and worktree. -DSH_STAMP=$(date -u +%Y%m%dT%H%M%SZ) -DSH_STAGING_BRANCH=dsh-staging/$DSH_STAMP -DSH_STAGING=$DSH_SOURCE/staging-$DSH_STAMP - -# --- path helpers --------------------------------------------------------------- -# Every path comparison below runs on physical paths. Git always reports resolved -# paths, so comparing one against an unresolved path disagrees whenever a symlink -# sits anywhere above the checkout — a symlinked home directory is enough, and -# macOS reaches every mktemp path that way through /var -> private/var. The -# mismatch silently misclassifies an existing managed install as a foreign clone -# and builds a second container beside the real one. -# `git rev-parse --path-format=absolute` would do this, but it needs git 2.31+. -# -# A not-yet-created directory (the container on a fresh install) has no physical -# path. Falling back here rather than at each call site keeps every caller a -# plain assignment, so no site can compare against an empty path by forgetting -# its own fallback. -resolve_dir() { CDPATH= cd -- "$1" 2>/dev/null && pwd -P || printf '%s\n' "$1"; } - -# --- in-repo detection --------------------------------------------------------- -# Under `curl ... | sh` the script text arrives on stdin, so $0 is the shell -# name and no file path resolves; running a checked-out copy (`sh -# scripts/install.sh`) makes $0 the script file. When $0 is a readable file whose -# parent is a scripts/ dir inside a real dsh checkout (bin/dsh launcher present), -# this is in-repo mode: never clone, never touch that working tree. An explicit -# DSH_SOURCE pointing elsewhere opts back into the clone/worktree path. -IN_REPO=0 -DSH_CHECKOUT='' -if [ -f "$0" ]; then - _self_dir=$(resolve_dir "$(dirname -- "$0")") - if [ -n "$_self_dir" ]; then - # Physical without its own resolve_dir: dirname is textual, so trimming a - # resolved path leaves one. The comparison below depends on that. - _repo_root=$(dirname -- "$_self_dir") - if [ "$(basename -- "$_self_dir")" = scripts ] \ - && [ -x "$_repo_root/bin/dsh" ] && [ -f "$_repo_root/scripts/install.sh" ]; then - # Compare the explicit DSH_SOURCE physically: an unresolved but equivalent - # path must still count as "the caller meant this checkout". - _src_resolved=$(resolve_dir "$DSH_SOURCE") - if [ "$DSH_SOURCE_EXPLICIT" = 0 ] || [ "$_src_resolved" = "$_repo_root" ]; then - IN_REPO=1 - DSH_CHECKOUT=$_repo_root - fi - fi - fi -fi - -# --- terminal-aware prompting -------------------------------------------------- -# stdin is the piped script, so read the controlling terminal for input. -if { true /dev/null; then - HAS_TTY=1 - # Restore terminal echo on exit or interrupt: ask_secret disables echo between - # its stty toggles, and dash (a common `sh`) does not run an EXIT trap when the - # shell is killed by a signal, so the fatal signals need their own handler. A - # successful run ends in exec, which replaces this process and drops the traps. - trap 'stty echo /dev/null || true' EXIT - trap 'stty echo /dev/null || true; exit 130' INT TERM HUP -else - HAS_TTY=0 -fi - -# Colour only when writing to a terminal. -if [ -t 1 ]; then - B=$(printf '\033[1m'); DIM=$(printf '\033[2m'); RED=$(printf '\033[31m') - GRN=$(printf '\033[32m'); YEL=$(printf '\033[33m'); RST=$(printf '\033[0m') -else - B=''; DIM=''; RED=''; GRN=''; YEL=''; RST='' -fi - -info() { printf '%s==>%s %s\n' "$GRN" "$RST" "$1"; } -step() { printf '\n%s==>%s %s%s%s\n' "$GRN" "$RST" "$B" "$1" "$RST"; } -warn() { printf '%s warn%s %s\n' "$YEL" "$RST" "$1" >&2; } -die() { printf '%serror%s %s\n' "$RED" "$RST" "$1" >&2; exit 1; } - -# ask PROMPT [DEFAULT] -> answer on stdout (plain-text line). -ask() { - [ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell" - printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty - IFS= read -r _ans answer on stdout, with terminal echo suppressed. -ask_secret() { - [ "$HAS_TTY" = 1 ] || die "no terminal available for input; re-run in an interactive shell" - printf '%s%s%s ' "$B" "$1" "$RST" >/dev/tty - stty -echo /dev/null || true - IFS= read -r _sec /dev/null || true - printf '\n' >/dev/tty - printf '%s' "$_sec" -} - -# confirm PROMPT [Y] -> exit 0 on yes. Default is no unless second arg is "Y". -confirm() { - _def=${2:-N} - if [ "$HAS_TTY" != 1 ]; then - [ "$_def" = Y ] # non-interactive: take the default - return - fi - if [ "$_def" = Y ]; then _hint='[Y/n]'; else _hint='[y/N]'; fi - printf '%s%s%s %s ' "$B" "$1" "$RST" "$_hint" >/dev/tty - IFS= read -r _r /dev/null 2>&1 || die "git is required but not found. Install git, then re-run." -info "git ... ok" - -# Node ^22.19.0 || >=24.0.0 (see the root package.json "engines" field). -node_ok() { - command -v node >/dev/null 2>&1 || return 1 - _v=$(node -v 2>/dev/null) || return 1 - _v=${_v#v} - _major=${_v%%.*} - _rest=${_v#*.} - _minor=${_rest%%.*} - case "$_major" in ''|*[!0-9]*) return 1 ;; esac - case "$_minor" in ''|*[!0-9]*) _minor=0 ;; esac - [ "$_major" -ge 24 ] && return 0 - [ "$_major" -eq 22 ] && [ "$_minor" -ge 19 ] && return 0 - return 1 -} -if node_ok; then - info "node $(node -v) ... ok" -else - if command -v node >/dev/null 2>&1; then - die "Node $(node -v) is unsupported. dsh needs ^22.19.0 || >=24.0.0 — upgrade Node, then re-run." - fi - die "Node is required but not found. Install Node ^22.19.0 || >=24, then re-run." -fi - -# pnpm is the only dependency we offer to install for you. -if command -v pnpm >/dev/null 2>&1; then - info "pnpm $(pnpm --version) ... ok" -else - warn "pnpm is not installed." - if confirm "Install pnpm now?" Y; then - if command -v corepack >/dev/null 2>&1 && corepack enable pnpm >/dev/null 2>&1; then - info "enabled pnpm via corepack" - elif command -v npm >/dev/null 2>&1 && npm install -g pnpm >/dev/null 2>&1; then - info "installed pnpm via npm" - else - die "could not install pnpm automatically. Install it (https://pnpm.io/installation), then re-run." - fi - command -v pnpm >/dev/null 2>&1 || die "pnpm still not on PATH after install. Open a new shell, then re-run." - else - die "pnpm is required. Install it (https://pnpm.io/installation), then re-run." - fi -fi - -# --- 2. resolve the repository and lay out the staging worktree --------------- -# The source container owns staging worktrees and `current`; the repository is -# *discovered*, not owned. A curl install discovers it by cloning to $DSH_MASTER; -# in-repo adoption discovers it from the checkout. Both then run one shared -# worktree/exclude/lock path, so an arbitrary clone and a managed install -# converge on the same layout. -# -# REPO_COMMON is the shared git directory every worktree of the repository -# points at; REPO_ROOT is the working tree that owns it (the master clone). -REPO_COMMON='' -REPO_ROOT='' - -if [ "$IN_REPO" = 1 ]; then - step "Using existing checkout at $DSH_CHECKOUT" - info "running from inside the repo — never cloning, and DSH_REF is ignored" - - # Resolve the repository behind the checkout. --git-common-dir returns the - # SHARED git dir, so a linked worktree resolves to the real clone rather than - # itself; it is relative for a plain clone, so anchor it before resolving. - # Require the resolved git dir to exist: resolve_dir echoes its argument back - # for a missing path, so test the directory rather than the returned string. - if _common=$(git -C "$DSH_CHECKOUT" rev-parse --git-common-dir 2>/dev/null) && [ -n "$_common" ]; then - case "$_common" in /*) ;; *) _common=$DSH_CHECKOUT/$_common ;; esac - [ -d "$_common" ] && REPO_COMMON=$(resolve_dir "$_common") - fi - [ -n "$REPO_COMMON" ] || die "$DSH_CHECKOUT is not a git repository — cannot adopt it." - REPO_ROOT=$(dirname -- "$REPO_COMMON") - - # Reuse the container when the repository already lives inside it (the normal - # managed install re-running its own script); otherwise treat that clone as - # its own master and keep worktrees in the default container. - _src_resolved=$(resolve_dir "$DSH_SOURCE") - case "$REPO_ROOT/" in - "$_src_resolved"/*) info "repository $REPO_ROOT is already inside $DSH_SOURCE" ;; - *) info "adopting clone $REPO_ROOT as its own master" ;; - esac - DSH_MASTER=$REPO_ROOT -else - step "Fetching source into $DSH_MASTER" - if [ -d "$DSH_MASTER/.git" ]; then - info "existing master clone found — updating" - git -C "$DSH_MASTER" fetch origin "$DSH_REF" - # Reset the master checkout to the freshly fetched tip. FETCH_HEAD (not - # origin/) so this resolves for a tag as well as a branch, and -B makes - # the re-run idempotent whether or not DSH_REF changed since the last install. - git -C "$DSH_MASTER" checkout -q -B "$DSH_REF" FETCH_HEAD - else - mkdir -p "$DSH_SOURCE" - git clone --branch "$DSH_REF" "$DSH_REPO" "$DSH_MASTER" - fi - # Physical on both branches: REPO_ROOT is compared against resolved paths - # below, and REPO_COMMON stays symmetric with it so neither can be read as - # carrying a different kind of path. - REPO_COMMON=$(resolve_dir "$DSH_MASTER/.git") - REPO_ROOT=$(resolve_dir "$DSH_MASTER") -fi - -step "Adding staging worktree at $DSH_STAGING" -[ -e "$DSH_STAGING" ] && die "staging path $DSH_STAGING already exists — remove it or set DSH_SOURCE elsewhere, then re-run." -mkdir -p "$DSH_SOURCE" -# The staging worktree owns the branch dsh runs from; the repository stays as -# the fetch/upgrade base and is never a launcher target. A clone install -# branches from the ref it just fetched; adoption branches from the checkout's -# HEAD so the contributor's committed work is what runs. -if [ "$IN_REPO" = 1 ]; then - git -C "$DSH_CHECKOUT" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD -else - git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" FETCH_HEAD 2>/dev/null \ - || git -C "$DSH_MASTER" worktree add -b "$DSH_STAGING_BRANCH" "$DSH_STAGING" HEAD -fi -# Exclude the per-worktree merge lock in the shared git dir's info/exclude, -# which every linked worktree inherits. -_exclude="$REPO_COMMON/info/exclude" -if [ -f "$_exclude" ] && ! grep -qxF '.agents/merge.lock' "$_exclude" 2>/dev/null; then - printf '.agents/merge.lock\n' >>"$_exclude" -fi -mkdir -p "$DSH_STAGING/.agents" -: >"$DSH_STAGING/.agents/merge.lock" - -# --- 3. install dependencies (no build; the launcher runs from source) -------- -step "Installing dependencies with pnpm (this can take a while)" -( cd "$DSH_STAGING" && pnpm install ) - -[ -x "$DSH_STAGING/bin/dsh" ] || die "launcher $DSH_STAGING/bin/dsh missing after install — is DSH_REF a branch that ships apps/cli?" - -# --- 4. put `dsh` on PATH ------------------------------------------------------ -# Every install goes through a stable `current` symlink so an upgrade repoints -# one symlink (current -> new worktree) and the PATH launcher never moves: -# PATH/dsh -> current/bin/dsh -> /bin/dsh. -step "Linking dsh into $DSH_BIN_DIR" -mkdir -p "$DSH_BIN_DIR" -# The launcher must resolve to a staging worktree, never to the repository -# itself: an upgrade repoints `current`, so aliasing it onto the master clone -# would make every upgrade rewrite the fetch/upgrade base. Compare physical -# paths — a symlinked or unresolved path would slip past a string compare. -_staging_resolved=$(resolve_dir "$DSH_STAGING") -[ "$_staging_resolved" = "$REPO_ROOT" ] \ - && die "refusing to point $DSH_CURRENT at the repository $REPO_ROOT — the launcher must resolve to a staging worktree." -# Point `current` at this staging worktree with `ln -sfn`: -f replaces an -# existing `current` (re-run or upgrade) and -n stops `ln` from dereferencing -# an existing symlink-to-directory and dropping the new link *inside* the old -# worktree. `mv` is unusable here — BSD/macOS `mv` follows the existing dir -# symlink the same way. The swap is one unlink+symlink pair on a local fs; the -# installer holds no other process racing this path. -ln -sfn "$DSH_STAGING" "$DSH_CURRENT" -info "pointed $DSH_CURRENT -> $DSH_STAGING" -DSH_LAUNCH_TARGET=$DSH_CURRENT/bin/dsh -ln -sf "$DSH_LAUNCH_TARGET" "$DSH_BIN_DIR/dsh" -info "linked $DSH_BIN_DIR/dsh -> $DSH_LAUNCH_TARGET" - -case ":$PATH:" in - *":$DSH_BIN_DIR:"*) ON_PATH=1 ;; - *) ON_PATH=0 ;; -esac -if [ "$ON_PATH" = 0 ]; then - warn "$DSH_BIN_DIR is not on your PATH." - _line="export PATH=\"$DSH_BIN_DIR:\$PATH\"" - _rc='' - _sh=${SHELL:-} # SHELL may be unset; word-removal on an unset var trips set -u under dash. - case "${_sh##*/}" in - zsh) _rc="$HOME/.zshrc" ;; - bash) _rc="$HOME/.bashrc" ;; - esac - if [ -n "$_rc" ] && [ -f "$_rc" ] && grep -qF "$_line" "$_rc" 2>/dev/null; then - info "$_rc already exports $DSH_BIN_DIR — open a new shell to pick it up" - elif [ -n "$_rc" ] && confirm "Add it to $_rc?" Y; then - printf '\n# Added by the dsh installer\n%s\n' "$_line" >>"$_rc" - info "updated $_rc — run 'source $_rc' or open a new shell to pick it up" - else - warn "add this line to your shell profile yourself:" - printf ' %s\n' "$_line" - fi -fi - -# --- 5. credentials ------------------------------------------------------------ -# Mirror app-boot's resolveDshHome precedence ($DSH_HOME, else ~/.dsh) so creds land where dsh reads them. -if [ -n "${DSH_HOME:-}" ]; then - CONF="$DSH_HOME" -else - CONF="$HOME/.dsh" -fi -ENV_FILE="$CONF/.env" - -step "Configuring credentials" -if [ -f "$ENV_FILE" ] && grep -q '^DEEPSEEK_API_KEY=' "$ENV_FILE" 2>/dev/null; then - info "DEEPSEEK_API_KEY already set in $ENV_FILE" - if ! confirm "Replace it?" N; then - SKIP_CREDS=1 - fi -fi -if [ "${SKIP_CREDS:-0}" != 1 ]; then - if [ "$HAS_TTY" = 1 ]; then - API_KEY=$(ask_secret "DeepSeek API key (input hidden):") - if [ -z "$API_KEY" ]; then - warn "no key entered — skipping. Set DEEPSEEK_API_KEY in $ENV_FILE before using dsh." - else - BASE_URL=$(ask "DeepSeek base URL (optional, Enter to skip):") - mkdir -p "$CONF" - # The installer owns exactly the two DEEPSEEK_* lines; any other lines the - # user keeps in this .env are preserved. The rewrite happens in a subshell - # so umask 077 (which closes the create-time permission race) does not leak - # into the exec'd dsh, and lands atomically via a same-dir temp + mv. - _tmp="$ENV_FILE.dsh.$$" - ( - umask 077 - if [ -f "$ENV_FILE" ]; then - grep -v -e '^DEEPSEEK_API_KEY=' -e '^DEEPSEEK_BASE_URL=' "$ENV_FILE" >"$_tmp" || true - else - : >"$_tmp" - fi - printf 'DEEPSEEK_API_KEY=%s\n' "$API_KEY" >>"$_tmp" - if [ -n "$BASE_URL" ]; then printf 'DEEPSEEK_BASE_URL=%s\n' "$BASE_URL" >>"$_tmp"; fi - ) - mv "$_tmp" "$ENV_FILE" - chmod 600 "$ENV_FILE" 2>/dev/null || true - info "wrote $ENV_FILE" - fi - else - warn "no terminal for credential input — set DEEPSEEK_API_KEY in $ENV_FILE before using dsh." - fi -fi - -# --- 6. build and launch the Web interface ------------------------------------- -step "Done" -if [ "$HAS_TTY" = 1 ]; then - step "Building DeepSeek Harness for Web UI" - ( cd "$DSH_STAGING" && pnpm run build ) - info "launching Web UI — run 'dsh web' anytime to start again" - exec "$DSH_BIN_DIR/dsh" web { repositoryRef: 'abc123', })).toBe( '[B](./reference/b.md#part) ' - + '[source](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/packages/tool.ts#L2) ' + + '[source](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/packages/tool.ts#L2) ' + '[web](https://example.com)\n', ) }) @@ -130,7 +130,7 @@ describe('rewriteMarkdown', () => { pages, repoRoot: root, repositoryRef: 'abc123', - })).toBe('![logo](https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/abc123/packages/logo.svg)\n') + })).toBe('![logo](https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/abc123/packages/logo.svg)\n') }) it('hands an image to the placer and uses the URL it returns', () => { @@ -209,7 +209,7 @@ describe('rewriteMarkdown', () => { repositoryRef: 'abc123', })).toBe( '[title](./reference/b.md "b.md") ' - + '[escaped](https://github.com/deepseek-ai/deepseek-harness-sdk/blob/abc123/docs/x(y).md)\n', + + '[escaped](https://github.com/deepseek-ai/deepseek-harness/blob/abc123/docs/x(y).md)\n', ) }) diff --git a/scripts/project-doc-site.ts b/scripts/project-doc-site.ts index 55c7204d78..e7acc73998 100644 --- a/scripts/project-doc-site.ts +++ b/scripts/project-doc-site.ts @@ -15,7 +15,7 @@ import { gfm } from 'micromark-extension-gfm' import type { Nodes } from 'mdast' import { docsPages, type DocsLocale, type DocsPage } from '../website/docs.ts' -const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness-sdk' +const REPOSITORY_URL = 'https://github.com/deepseek-ai/deepseek-harness' const root = resolve(import.meta.dirname, '..') const generatedRoot = resolve(root, 'website/.generated') @@ -209,7 +209,7 @@ function githubTarget( image: boolean, ): string { const path = repoPath(absPath, repoRoot) - if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness-sdk/${repositoryRef}/${path}${suffix}` + if (image) return `https://raw.githubusercontent.com/deepseek-ai/deepseek-harness/${repositoryRef}/${path}${suffix}` const kind = lstatSync(absPath).isDirectory() ? 'tree' : 'blob' const lineSuffix = line === undefined ? suffix : `#L${line}` return `${REPOSITORY_URL}/${kind}/${repositoryRef}/${path}${lineSuffix}` diff --git a/scripts/publish-npm-baseline.ts b/scripts/publish-npm-baseline.ts index 4a33f32e1e..320f11997f 100644 --- a/scripts/publish-npm-baseline.ts +++ b/scripts/publish-npm-baseline.ts @@ -258,7 +258,9 @@ class WorkspacePackageSet { const name = expectString(manifest, 'name', manifestPath) const version = expectString(manifest, 'version', manifestPath) const isVendored = manifestPath.startsWith('vendor/') - if (!isVendored && !name.startsWith('@deepseek-ai/')) { + // Vendored packages are rescoped too (vendor/README.md), so publication + // never carries an upstream name that would squat it on the registry. + if (!name.startsWith('@deepseek-ai/')) { throw new Error(`${manifestPath} must name an @deepseek-ai package`) } if (name === '@deepseek-ai/dsh-root') { diff --git a/scripts/release/bump.ts b/scripts/release/bump.ts new file mode 100644 index 0000000000..e79b7a0e9c --- /dev/null +++ b/scripts/release/bump.ts @@ -0,0 +1,398 @@ +/** + * Bump one release family's version and commit it, so the published version is + * readable from the repository rather than derived inside CI + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). + * + * The dsh family shares one version across its members and the workspace root: + * `major`, `minor`, `patch`, or an explicit `x.y.z` (including a prerelease such + * as `0.0.1-rc.1`). The vendored family has one version line per package and + * publishes only what changed since that package's own `vendor--v*` + * tag, which is the record of the commit it last published from. + * + * The version lands in the manifests, the lockfile follows, and a human creates + * the tag after the commit merges. CI never writes to the repository. + */ + +import { readFileSync, writeFileSync } from 'node:fs' +import { join, matchesGlob } from 'node:path' +import { parseArgs } from 'node:util' +import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts' +import { attempt, capture, isEntry } from './process.ts' + +/** Files npm publishes whether or not `files` lists them. */ +const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as const + +/** + * Inputs that decide what a built payload contains. A package whose `files` + * selects `lib/` publishes build output that git does not track, so a change to + * the sources or the build configuration changes the tarball while no published + * path appears in the diff. + */ +const BUILD_INPUTS = ['src/**', 'tsconfig*.json', 'tsdown.config.*', 'build.config.*'] as const + +/** Release types the dsh family accepts besides an explicit version. */ +const RELEASE_TYPES = ['major', 'minor', 'patch'] as const + +/** The workspace root manifest, which carries the dsh family's version. */ +const ROOT_MANIFEST = 'package.json' + +/** One manifest the bump rewrites, and the tag its new version will carry. */ +interface PlannedVersion { + /** Repository-relative manifest path. */ + readonly manifestPath: string + /** Label for the log line. */ + readonly label: string + /** The version the manifest currently carries. */ + readonly from: string + /** The version to write. */ + readonly to: string + /** The tag this version publishes from, or undefined for the workspace root. */ + readonly tag: string | undefined +} + +/** + * Split a version into its release numbers, discarding any prerelease segment. + * @param version - the current version. + * @returns Major, minor, and patch. + */ +function releaseNumbers(version: string): [number, number, number] { + const match = /^(\d+)\.(\d+)\.(\d+)(?:-[0-9A-Za-z.-]+)?$/.exec(version) + if (match === null) throw new Error(`cannot read release numbers from version ${version}`) + return [Number(match[1]), Number(match[2]), Number(match[3])] +} + +/** + * Order two versions by their release numbers alone. + * @param left - one version. + * @param right - the other version. + * @returns Negative when `left` is lower, positive when higher, zero when equal. + */ +function compareReleaseNumbers(left: string, right: string): number { + const [leftMajor, leftMinor, leftPatch] = releaseNumbers(left) + const [rightMajor, rightMinor, rightPatch] = releaseNumbers(right) + return leftMajor - rightMajor || leftMinor - rightMinor || leftPatch - rightPatch +} + +/** + * The prerelease segment of a version, or undefined when it has none. + * @param version - the version to read. + * @returns The segment after the first `-`. + */ +function prereleaseOf(version: string): string | undefined { + const index = version.indexOf('-') + return index === -1 ? undefined : version.slice(index + 1) +} + +/** + * Order two versions by semver precedence. + * + * Git's version sort cannot stand in for this: `--sort=v:refname` places + * `4.0.1-rc.1` above `4.0.1`, while semver gives a prerelease lower precedence + * than the release it precedes. Prerelease identifiers compare field by field, + * numeric fields numerically, so `rc.10` outranks `rc.1`. + * @param left - one version. + * @param right - the other version. + * @returns Negative when `left` is lower, positive when higher, zero when equal. + */ +export function compareVersions(left: string, right: string): number { + const numbers = compareReleaseNumbers(left, right) + if (numbers !== 0) return numbers + const leftPre = prereleaseOf(left) + const rightPre = prereleaseOf(right) + if (leftPre === undefined || rightPre === undefined) { + if (leftPre === rightPre) return 0 + return leftPre === undefined ? 1 : -1 + } + const leftFields = leftPre.split('.') + const rightFields = rightPre.split('.') + for (let index = 0; index < Math.max(leftFields.length, rightFields.length); index += 1) { + const leftField = leftFields[index] + const rightField = rightFields[index] + // A shorter identifier list has lower precedence when all its fields match. + if (leftField === undefined) return -1 + if (rightField === undefined) return 1 + if (leftField === rightField) continue + const leftNumeric = /^\d+$/.test(leftField) + const rightNumeric = /^\d+$/.test(rightField) + if (leftNumeric && rightNumeric) return Number(leftField) - Number(rightField) + // Numeric fields have lower precedence than alphanumeric ones. + if (leftNumeric !== rightNumeric) return leftNumeric ? -1 : 1 + return leftField < rightField ? -1 : 1 + } + return 0 +} + +/** + * The next dsh version. + * @param current - the family's current shared version. + * @param request - `major`, `minor`, `patch`, or an explicit version. + * @returns The target version. + */ +function nextSharedVersion(current: string, request: string): string { + if (!RELEASE_TYPES.includes(request as typeof RELEASE_TYPES[number])) { + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(request)) { + throw new Error(`usage: release:dsh , got ${request}`) + } + return request + } + const [major, minor, patch] = releaseNumbers(current) + if (request === 'major') return `${String(major + 1)}.0.0` + if (request === 'minor') return `${String(major)}.${String(minor + 1)}.0` + return `${String(major)}.${String(minor)}.${String(patch + 1)}` +} + +/** + * The version a vendored package publishes next. + * + * The baseline is the higher of the manifest version and the last published + * version: a vendor re-sync restores upstream's version, which is lower than + * what this repository already published, and incrementing that would name a + * version the registry already carries. + * + * A prerelease does not consume its own release numbers. Publishing + * `4.0.1-rc.1` leaves `4.0.1` free, so the next stable version is `4.0.1` + * rather than `4.0.2`, and a second prerelease keeps those numbers too. + * @param current - the package's manifest version. + * @param published - the version its newest tag names, when it has one. + * @param prerelease - prerelease identifier to append, for a rehearsal publication. + * @returns The target version. + */ +export function nextVendorVersion( + current: string, + published: string | undefined, + prerelease?: string, +): string { + const ahead = published !== undefined && compareReleaseNumbers(published, current) > 0 + const baseline = ahead ? published : current + const [major, minor, patch] = releaseNumbers(baseline) + // Reuse the numbers when the published version that set them is a prerelease + // of them; increment when a stable release already holds them. + const reuse = ahead && published.includes('-') + const numbers = reuse + ? `${String(major)}.${String(minor)}.${String(patch)}` + : `${String(major)}.${String(minor)}.${String(patch + 1)}` + return prerelease === undefined ? numbers : `${numbers}-${prerelease}` +} + +/** + * Whether a repository-relative path reaches the member's published payload. + * @param member - the member the path belongs to. + * @param path - repository-relative path. + * @returns True when `files`, npm's always-published set, or a build input selects it. + */ +export function reachesPayload(member: ReleaseMember, path: string): boolean { + const relative = path.slice(member.directory.length + 1) + const files = member.manifest.files + const selected = Array.isArray(files) ? files.filter((entry): entry is string => typeof entry === 'string') : [] + const built = selected.some(pattern => pattern.startsWith('lib')) + const patterns = [...ALWAYS_PUBLISHED, ...selected, ...built ? BUILD_INPUTS : []] + return patterns.some(pattern => + matchesGlob(relative, pattern) || matchesGlob(relative, `${pattern}/**`) || relative === pattern) +} + +/** + * The newest version a member published, read from its tags. + * @param family - the member's family. + * @param member - the member. + * @returns The version, or undefined when the member never published. + */ +function lastPublishedVersion(family: ReleaseFamily, member: ReleaseMember): string | undefined { + const prefix = family.tagPrefixFor(member) + const versions = capture('git', ['tag', '--list', `${prefix}*`]) + .split('\n').filter(line => line !== '').map(tag => tag.slice(prefix.length)) + if (versions.length === 0) return undefined + return versions.reduce((newest, candidate) => compareVersions(candidate, newest) > 0 ? candidate : newest) +} + +/** + * Confirm the registry carries the version a tag names. + * + * A tag is a commit pointer, not proof of publication: a tag pushed for a + * publication that then failed would otherwise read as "already published" and + * skip the package indefinitely. Querying a private package needs credentials, + * so an unauthenticated machine reports the gap instead of failing. + * @param name - package name. + * @param version - the version the tag names. + */ +function confirmPublished(name: string, version: string): void { + const result = attempt('npm', ['view', `${name}@${version}`, 'version']) + if (result.status === 0) return + const output = `${result.stdout}${result.stderr}` + if (output.includes('ENEEDAUTH') || output.includes('E401') || output.includes('E403')) { + console.log(`release bump: cannot reach the registry for ${name}@${version}; skipping the tag check`) + return + } + if (output.includes('E404') || output.includes('404 Not Found')) { + throw new Error( + `${name}@${version} is tagged but absent from the registry.` + + '\nThe tag was pushed for a publication that did not complete: re-run that publish, or delete the tag.', + ) + } + throw new Error(`npm view ${name}@${version} failed:\n${output}`) +} + +/** + * Write a version into a manifest, preserving formatting and key order. + * @param root - repository root. + * @param manifestPath - repository-relative manifest path. + * @param from - the version the manifest currently carries. + * @param to - the target version. + */ +function writeVersion(root: string, manifestPath: string, from: string, to: string): void { + const path = join(root, manifestPath) + const text = readFileSync(path, 'utf8') + const line = `"version": "${from}"` + if (!text.includes(line)) throw new Error(`${manifestPath}: cannot locate ${line}`) + writeFileSync(path, text.replace(line, `"version": "${to}"`)) +} + +/** + * Read the workspace root version. + * @param root - repository root. + * @returns The root manifest version. + */ +function rootVersion(root: string): string { + const manifest: unknown = JSON.parse(readFileSync(join(root, ROOT_MANIFEST), 'utf8')) + const version = (manifest as Record).version + if (typeof version !== 'string') throw new Error('package.json must declare a string version') + return version +} + +/** + * Plan the dsh family's rewrite: one version for every member and the root. + * @param family - the dsh family. + * @param root - repository root. + * @param members - the family's members. + * @param request - `major`, `minor`, `patch`, or an explicit version. + * @returns The manifests to rewrite and the shared target version. + */ +function planShared( + family: ReleaseFamily, + root: string, + members: readonly ReleaseMember[], + request: string, +): { planned: PlannedVersion[]; version: string } { + const [first] = members + if (first === undefined) throw new Error(`release family ${family.id} has no members`) + const version = nextSharedVersion(first.version, request) + // The workspace root carries the family version too: the workspace constraint + // requires every member's version to equal the root's. + const planned: PlannedVersion[] = [ + { manifestPath: ROOT_MANIFEST, label: ROOT_MANIFEST, from: rootVersion(root), to: version, tag: undefined }, + ] + for (const member of members) { + planned.push({ + manifestPath: join(member.directory, 'package.json'), + label: member.directory, + from: member.version, + to: version, + tag: family.tagFor({ ...member, version }), + }) + } + return { planned, version } +} + +/** + * Plan the vendored family's rewrite: every package whose payload changed since + * it last published. + * @param family - the vendored family. + * @param members - the family's members. + * @param prerelease - prerelease identifier to append, for a rehearsal publication. + * @returns The manifests to rewrite. + */ +function planPerPackage( + family: ReleaseFamily, + members: readonly ReleaseMember[], + prerelease: string | undefined, +): PlannedVersion[] { + const planned: PlannedVersion[] = [] + for (const member of members) { + const published = lastPublishedVersion(family, member) + if (published !== undefined) { + confirmPublished(member.name, published) + const since = `${family.tagPrefixFor(member)}${published}` + const changed = capture('git', ['diff', '--name-only', `${since}..HEAD`, '--', member.directory]) + .split('\n').filter(line => line !== '') + if (!changed.some(path => reachesPayload(member, path))) continue + } + const to = nextVendorVersion(member.version, published, prerelease) + planned.push({ + manifestPath: join(member.directory, 'package.json'), + label: member.directory, + from: member.version, + to, + tag: family.tagFor({ ...member, version: to }), + }) + } + return planned +} + +/** + * Bump the family named by `--family` and commit; `--dry-run` only reports the + * plan. `--prerelease rc.1` makes the vendored family publish a rehearsal + * version, which never takes the stable dist-tag. + */ +function main(): void { + const { values, positionals } = parseArgs({ + options: { + family: { type: 'string' }, + prerelease: { type: 'string' }, + 'dry-run': { type: 'boolean', default: false }, + }, + allowPositionals: true, + }) + if (values.family === undefined) throw new Error('usage: bump.ts --family [version]') + + const family = releaseFamily(values.family) + const root = process.cwd() + const members = family.members(root) + family.verifyVersions(members) + + let planned: PlannedVersion[] + let sharedVersion: string | undefined + if (family.id === 'dsh') { + const request = positionals[0] + if (request === undefined) throw new Error('usage: release:dsh ') + if (values.prerelease !== undefined) { + throw new Error('release:dsh takes the prerelease in its version argument, as in 0.0.1-rc.1') + } + const shared = planShared(family, root, members, request) + planned = shared.planned + sharedVersion = shared.version + } else { + if (positionals.length > 0) throw new Error('release:vendor takes no version: each package increments its own patch') + if (values.prerelease !== undefined && !/^[0-9A-Za-z.-]+$/.test(values.prerelease)) { + throw new Error(`--prerelease must be a semver prerelease identifier, got ${values.prerelease}`) + } + planned = planPerPackage(family, members, values.prerelease) + } + + if (planned.length === 0) { + console.log(`release bump: family ${family.id}, nothing changed since publication`) + return + } + + const dryRun = values['dry-run'] + if (!dryRun) { + for (const entry of planned) writeVersion(root, entry.manifestPath, entry.from, entry.to) + capture('pnpm', ['install', '--lockfile-only']) + } + + const summary = sharedVersion + ?? planned.map(entry => `${entry.label.replace('vendor/', '')} ${entry.to}`).join(', ') + console.log(`release bump: family ${family.id} -> ${summary}`) + for (const entry of planned) console.log(` ${entry.label}: ${entry.from} -> ${entry.to}`) + + if (dryRun) { + console.log('release bump: dry run, nothing written') + return + } + capture('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => entry.manifestPath)]) + capture('git', ['commit', '-m', `release(${family.id}): ${summary}`]) + console.log('release bump: committed. After this merges to master, tag it:') + for (const tag of [...new Set(planned.map(entry => entry.tag).filter(tag => tag !== undefined))]) { + console.log(` git tag ${tag} && git push origin ${tag}`) + } +} + +if (isEntry(import.meta.url)) main() diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts new file mode 100644 index 0000000000..65ebd078ee --- /dev/null +++ b/scripts/release/families.spec.ts @@ -0,0 +1,176 @@ +/** Release family discovery, publish order, tag naming, and the bump judgements. */ + +import { describe, expect, it } from 'vitest' +import { releaseFamily, type ReleaseMember } from './families.ts' +import { compareVersions, nextVendorVersion, reachesPayload } from './bump.ts' + +/** + * A release member standing in for a manifest on disk. + * @param directory - repository-relative package directory. + * @param name - package name. + * @param manifest - manifest fields the subject reads. + * @returns The member. + */ +function member(directory: string, name: string, manifest: Record = {}): ReleaseMember { + return { directory, name, version: '0.0.1', manifest } +} + +describe('release families', () => { + it('names one tag for the whole dsh family and one per vendored package', () => { + const dsh = releaseFamily('dsh') + const vendor = releaseFamily('vendor') + const cli = member('apps/cli', '@deepseek-ai/dsh') + const cordis = { ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' } + + expect(dsh.tagFor(cli)).toBe('dsh-v0.0.1') + expect(vendor.tagFor(cordis)).toBe('vendor-cordis-v4.0.1') + // The prefix is constructed, not recovered from a tag: a version with a + // hyphen would defeat any suffix-stripping. + expect(vendor.tagPrefixFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v') + expect(vendor.tagFor({ ...cordis, version: '4.0.0-rc.7' })).toBe('vendor-cordis-v4.0.0-rc.7') + }) + + it('rejects a family whose members disagree on the shared version', () => { + const dsh = releaseFamily('dsh') + const members = [member('apps/cli', '@deepseek-ai/dsh'), { ...member('apps/web', '@deepseek-ai/dsh-frontend'), version: '0.0.2' }] + + expect(() => { dsh.verifyVersions(members) }).toThrow(/must share one version/) + expect(() => { dsh.verifyVersions([members[0]!]) }).not.toThrow() + }) + + it('accepts independent vendored versions and rejects an unpublishable one', () => { + const vendor = releaseFamily('vendor') + const members = [ + { ...member('vendor/cordis', '@deepseek-ai/cordis'), version: '4.0.1' }, + { ...member('vendor/cosmokit', '@deepseek-ai/cosmokit'), version: '1.8.2' }, + ] + + expect(() => { vendor.verifyVersions(members) }).not.toThrow() + expect(() => { vendor.verifyVersions([{ ...members[0]!, version: 'latest' }]) }).toThrow(/unpublishable version/) + }) + + it('publishes a dependency before its consumer, and orders ties by name', () => { + const dsh = releaseFamily('dsh') + const members = [ + member('packages/a/consumer', '@deepseek-ai/dsh-consumer', { dependencies: { '@deepseek-ai/dsh-library': 'workspace:^' } }), + member('packages/a/library', '@deepseek-ai/dsh-library'), + member('packages/a/zebra', '@deepseek-ai/dsh-zebra'), + ] + + expect(dsh.publishOrder(members).map(entry => entry.name)).toEqual([ + '@deepseek-ai/dsh-library', + '@deepseek-ai/dsh-consumer', + '@deepseek-ai/dsh-zebra', + ]) + }) + + it('reports a runtime dependency cycle instead of emitting an arbitrary order', () => { + const dsh = releaseFamily('dsh') + const members = [ + member('packages/a/left', '@deepseek-ai/dsh-left', { dependencies: { '@deepseek-ai/dsh-right': 'workspace:^' } }), + member('packages/a/right', '@deepseek-ai/dsh-right', { dependencies: { '@deepseek-ai/dsh-left': 'workspace:^' } }), + ] + + expect(() => { dsh.publishOrder(members) }).toThrow(/dependency cycle/) + }) + + it('applies the harness payload policy to dsh and keeps upstream payloads for vendored packages', () => { + const dsh = releaseFamily('dsh') + const vendor = releaseFamily('vendor') + const harness = member('packages/a/library', '@deepseek-ai/dsh-library') + const vendored = member('vendor/cordis', '@deepseek-ai/cordis') + + expect(() => { dsh.validatePayload(harness, ['package/lib/index.js', 'package/src/index.ts']) }) + .toThrow(/publishes source file/) + expect(() => { vendor.validatePayload(vendored, ['package/lib/index.js', 'package/src/index.ts']) }).not.toThrow() + expect(() => { vendor.validatePayload(vendored, []) }).toThrow(/empty tarball/) + }) + + it('drives the installed entry only for the family that publishes one', () => { + expect(releaseFamily('dsh').installedEntry).toEqual({ packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' }) + expect(releaseFamily('vendor').installedEntry).toBeUndefined() + }) + + it('rejects an unknown family identifier', () => { + expect(() => { releaseFamily('native') }).toThrow(/unknown release family/) + }) +}) + +describe('vendored version baseline', () => { + it('drops an upstream prerelease segment and increments the patch', () => { + expect(nextVendorVersion('4.0.0-rc.7', undefined)).toBe('4.0.1') + expect(nextVendorVersion('1.0.0-rc.5', undefined)).toBe('1.0.1') + expect(nextVendorVersion('1.8.1', undefined)).toBe('1.8.2') + }) + + it('increments from the last published version when a re-sync restored a lower one', () => { + // Upstream moved rc.7 -> rc.8 after this repository published 4.0.1; + // incrementing the manifest alone would name 4.0.1 a second time. + expect(nextVendorVersion('4.0.0-rc.8', '4.0.1')).toBe('4.0.2') + expect(nextVendorVersion('4.1.0', '4.0.1')).toBe('4.1.1') + }) + + it('appends a rehearsal prerelease without consuming its release numbers', () => { + // A rehearsal burns 4.0.1-rc.1 and leaves 4.0.1 free, so the stable release + // that follows takes those same numbers instead of skipping to 4.0.2. + expect(nextVendorVersion('4.0.0-rc.7', undefined, 'rc.1')).toBe('4.0.1-rc.1') + expect(nextVendorVersion('4.0.0-rc.7', '4.0.1-rc.1', 'rc.2')).toBe('4.0.1-rc.2') + expect(nextVendorVersion('4.0.0-rc.7', '4.0.1-rc.1')).toBe('4.0.1') + expect(nextVendorVersion('4.0.0-rc.7', '4.0.1')).toBe('4.0.2') + }) +}) + +describe('version precedence', () => { + it('ranks a release above the prerelease it follows', () => { + // git --sort=v:refname disagrees, placing 4.0.1-rc.1 above 4.0.1, which is + // why the newest published version is chosen here rather than by git. + expect(compareVersions('4.0.1', '4.0.1-rc.1')).toBeGreaterThan(0) + expect(compareVersions('4.0.1-rc.1', '4.0.1')).toBeLessThan(0) + }) + + it('compares numeric prerelease fields numerically', () => { + expect(compareVersions('4.0.1-rc.10', '4.0.1-rc.1')).toBeGreaterThan(0) + expect(compareVersions('4.0.1-rc.2', '4.0.1-rc.10')).toBeLessThan(0) + }) + + it('ranks a numeric field below an alphanumeric one, and a shorter list below a longer', () => { + expect(compareVersions('4.0.1-1', '4.0.1-alpha')).toBeLessThan(0) + expect(compareVersions('4.0.1-rc', '4.0.1-rc.1')).toBeLessThan(0) + expect(compareVersions('4.0.2', '4.0.1')).toBeGreaterThan(0) + expect(compareVersions('4.0.1-rc.1', '4.0.1-rc.1')).toBe(0) + }) +}) + +describe('payload change judgement', () => { + const sourceShipping = member('vendor/cosmokit', '@deepseek-ai/cosmokit', { + files: ['lib/index.js', 'lib/types/**/*.d.ts', 'src'], + }) + const buildOutputOnly = member('vendor/cordis', '@deepseek-ai/cordis', { + files: ['lib/index.js', 'lib/types/**/*.d.ts', 'bin.js'], + }) + + it('counts the manifest and the files npm always publishes', () => { + expect(reachesPayload(sourceShipping, 'vendor/cosmokit/package.json')).toBe(true) + expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.md')).toBe(true) + expect(reachesPayload(sourceShipping, 'vendor/cosmokit/src/index.ts')).toBe(true) + }) + + it('counts build inputs for a package whose payload is build output', () => { + // cordis publishes lib/ only, and lib/ is not tracked: without this, a real + // source change reads as "nothing changed" and the next publish fails on a + // version whose bytes moved. + expect(reachesPayload(buildOutputOnly, 'vendor/cordis/src/context.ts')).toBe(true) + expect(reachesPayload(buildOutputOnly, 'vendor/cordis/tsconfig.json')).toBe(true) + }) + + it('ignores paths no tarball carries', () => { + expect(reachesPayload(sourceShipping, 'vendor/cosmokit/tests/unit.spec.ts')).toBe(false) + expect(reachesPayload(sourceShipping, 'vendor/cosmokit/CHANGELOG.md')).toBe(false) + // The README pattern is deliberately loose: over-reporting a change costs one + // unnecessary patch bump, while under-reporting fails the next publish on a + // version whose bytes moved. + expect(reachesPayload(sourceShipping, 'vendor/cosmokit/README.i18n.yaml')).toBe(true) + expect(reachesPayload(member('packages/a/library', '@deepseek-ai/dsh-library', { files: ['lib/index.js'] }), + 'packages/a/library/tests/library.spec.ts')).toBe(false) + }) +}) diff --git a/scripts/release/families.ts b/scripts/release/families.ts new file mode 100644 index 0000000000..e4c5fda5be --- /dev/null +++ b/scripts/release/families.ts @@ -0,0 +1,310 @@ +/** + * The three independent publish sequences this repository releases from + * (`packages/` + `apps/`, `vendor/`, and `native/`) and the two this module + * owns: `dsh` and `vendor`. Each family carries its own version baseline, tag + * naming, and publish set, so releasing one never republishes another + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). + * + * The family dimension lives here only. A new sequence adds a subclass and a + * `releaseFamilies()` entry; nothing else in the release scripts branches on it. + */ + +import { globSync, readFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { hasTypeRTRemoteNavigation, validateTarballPayload } from '../publication-payload.ts' + +/** Dependency sections that constrain publish order: a consumer must publish after its dependency. */ +const ORDER_SECTIONS = ['dependencies', 'optionalDependencies'] as const + +/** The workspace root manifest, which is never a release member. */ +const WORKSPACE_ROOT_PACKAGE = '@deepseek-ai/dsh-root' + +/** One publishable package of a release family. */ +export interface ReleaseMember { + /** Repository-relative package directory, for example `packages/core/session`. */ + readonly directory: string + /** Package name from its manifest. */ + readonly name: string + /** Package version from its manifest. */ + readonly version: string + /** The parsed manifest, for payload policy and publication checks. */ + readonly manifest: Readonly> +} + +/** + * Read and parse a JSON file. + * @param path - absolute file path. + * @returns The parsed object. + */ +function readManifest(path: string): Record { + const parsed: unknown = JSON.parse(readFileSync(path, 'utf8')) + if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { + throw new Error(`${path} is not a JSON object`) + } + return parsed as Record +} + +/** + * Read a required string field. + * @param manifest - parsed manifest. + * @param field - field name. + * @param context - manifest path for the error message. + * @returns The field value. + */ +function requireString(manifest: Record, field: string, context: string): string { + const value = manifest[field] + if (typeof value !== 'string' || value === '') throw new Error(`${context} must declare a string ${field}`) + return value +} + +/** The executable a family's installed artifacts are driven through. */ +export interface InstalledEntry { + /** Package that carries the executable. */ + readonly packageName: string + /** Path to the executable inside that package. */ + readonly binPath: string +} + +/** A release sequence: its members, its version baseline, and its tag naming. */ +export abstract class ReleaseFamily { + /** Workflow-facing identifier, also the `--family` argument. */ + abstract readonly id: string + + /** Glob patterns, relative to the repository root, that select this family's manifests. */ + abstract readonly patterns: readonly string[] + + /** Git tag prefix this family publishes from. */ + abstract readonly tagPrefix: string + + /** + * Discover this family's members. + * @param root - repository root. + * @returns Members sorted by directory, with names validated and deduplicated. + */ + members(root: string): ReleaseMember[] { + const manifestPaths = globSync([...this.patterns], { cwd: root }).sort() + if (manifestPaths.length === 0) throw new Error(`release family ${this.id} matched no manifests`) + + const members: ReleaseMember[] = [] + const seen = new Set() + for (const manifestPath of manifestPaths) { + const normalized = manifestPath.replaceAll('\\', '/') + const manifest = readManifest(resolve(root, manifestPath)) + const name = requireString(manifest, 'name', normalized) + const version = requireString(manifest, 'version', normalized) + if (name === WORKSPACE_ROOT_PACKAGE) throw new Error(`${normalized} selected the workspace root`) + if (!name.startsWith('@deepseek-ai/')) throw new Error(`${normalized} must name an @deepseek-ai package`) + if (seen.has(name)) throw new Error(`${name} appears twice in release family ${this.id}`) + seen.add(name) + members.push({ + directory: normalized.slice(0, normalized.length - '/package.json'.length), + name, + version, + manifest, + }) + } + return members + } + + /** + * Order members so every package publishes after the family members it depends on. + * @param members - this family's members. + * @returns The same members in publish order; ties break by name for determinism. + */ + publishOrder(members: readonly ReleaseMember[]): ReleaseMember[] { + const byName = new Map(members.map(member => [member.name, member])) + const ordered: ReleaseMember[] = [] + const placed = new Set() + const visiting = new Set() + + const visit = (member: ReleaseMember, path: readonly string[]): void => { + if (placed.has(member.name)) return + if (visiting.has(member.name)) { + throw new Error(`dependency cycle in release family ${this.id}: ${[...path, member.name].join(' -> ')}`) + } + visiting.add(member.name) + for (const dependency of this.orderEdges(member, byName)) { + visit(dependency, [...path, member.name]) + } + visiting.delete(member.name) + placed.add(member.name) + ordered.push(member) + } + + for (const member of [...members].sort((left, right) => left.name.localeCompare(right.name))) { + visit(member, []) + } + return ordered + } + + /** + * The family members one member depends on at runtime. + * @param member - the dependent member. + * @param byName - every family member by package name. + * @returns Dependencies inside this family, sorted by name. + */ + private orderEdges(member: ReleaseMember, byName: ReadonlyMap): ReleaseMember[] { + const edges: ReleaseMember[] = [] + for (const section of ORDER_SECTIONS) { + const dependencies = member.manifest[section] + if (dependencies === null || typeof dependencies !== 'object' || Array.isArray(dependencies)) continue + for (const name of Object.keys(dependencies)) { + const dependency = byName.get(name) + if (dependency !== undefined && dependency.name !== member.name) edges.push(dependency) + } + } + return edges.sort((left, right) => left.name.localeCompare(right.name)) + } + + /** + * Assert this family's version baseline holds across its members. + * @param members - this family's members. + */ + abstract verifyVersions(members: readonly ReleaseMember[]): void + + /** + * The tag prefix a member's versions are tagged under. Every tag for that + * member starts with it, which is how the last published version is found. + * @param member - the member being published. + * @returns The prefix, ending in `-v`. + */ + abstract tagPrefixFor(member: ReleaseMember): string + + /** + * The tag a member publishes from. + * @param member - the member being published. + * @returns The full tag name, without `refs/tags/`. + */ + tagFor(member: ReleaseMember): string { + return `${this.tagPrefixFor(member)}${member.version}` + } + + /** + * Check what a member's packed tarball carries. + * @param member - the packed member. + * @param files - every path inside its tarball. + */ + abstract validatePayload(member: ReleaseMember, files: readonly string[]): void + + /** + * The executable that proves this family's artifacts install and run, or + * `undefined` for a family that publishes no executable. + */ + abstract readonly installedEntry: InstalledEntry | undefined +} + +/** `packages/*` and `apps/*`: one shared version across the whole family. */ +class DshFamily extends ReleaseFamily { + readonly id = 'dsh' + readonly patterns = ['packages/*/*/package.json', 'apps/*/package.json'] as const + readonly tagPrefix = 'dsh-v' + + /** + * Require one version across the family, the way a single tag can name it. + * @param members - this family's members. + */ + verifyVersions(members: readonly ReleaseMember[]): void { + const versions = new Set(members.map(member => member.version)) + if (versions.size !== 1) { + const detail = members.map(member => `${member.directory}: ${member.version}`).join('\n') + throw new Error(`dsh release members must share one version:\n${detail}`) + } + } + + /** + * The single family prefix: every member shares one version, so one tag names it. + * @returns `dsh-v`. + */ + tagPrefixFor(): string { + return this.tagPrefix + } + + /** + * Reject source and declaration-map members, the repository's publication policy. + * @param member - the packed member. + * @param files - every path inside its tarball. + */ + validatePayload(member: ReleaseMember, files: readonly string[]): void { + validateTarballPayload(files, member.name, { + typeRTRemoteNavigation: hasTypeRTRemoteNavigation(member.manifest), + }) + } + + readonly installedEntry = { packageName: '@deepseek-ai/dsh', binPath: 'lib/bin.js' } +} + +/** `vendor/*`: every package keeps its own version line, so every package has its own tag. */ +class VendorFamily extends ReleaseFamily { + readonly id = 'vendor' + readonly patterns = ['vendor/*/package.json'] as const + readonly tagPrefix = 'vendor-' + + /** + * Accept independent versions; only reject a version this repository cannot publish. + * @param members - this family's members. + */ + verifyVersions(members: readonly ReleaseMember[]): void { + for (const member of members) { + if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(member.version)) { + throw new Error(`${member.directory} has an unpublishable version: ${member.version}`) + } + } + } + + /** + * A prefix per member, because one vendor release can carry several versions. + * @param member - the member being published. + * @returns `vendor--v`. + */ + tagPrefixFor(member: ReleaseMember): string { + return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v` + } + + /** + * Require the payload the vendored manifest declares, including upstream's + * `src` tree and declaration maps. + * + * The harness policy that rejects both does not apply here: these manifests + * export `./src/*` for source navigation, so dropping `src` would publish a + * package whose export map points at absent files. What must hold instead is + * that every path the manifest selects is present, which `files` already + * decides and `pnpm pack` already enforces. + * @param member - the packed member. + * @param files - every path inside its tarball. + */ + validatePayload(member: ReleaseMember, files: readonly string[]): void { + if (files.length === 0) throw new Error(`${member.name} packed an empty tarball`) + } + + /** No installed-entry probe: these are libraries a consumer imports, with no executable. */ + readonly installedEntry = undefined +} + +/** Every release family this module owns, in workflow order. */ +function releaseFamilies(): readonly ReleaseFamily[] { + return [new DshFamily(), new VendorFamily()] +} + +/** + * Resolve a family by its `--family` identifier. + * @param id - family identifier. + * @returns The family. + */ +export function releaseFamily(id: string): ReleaseFamily { + const family = releaseFamilies().find(candidate => candidate.id === id) + if (family === undefined) { + const known = releaseFamilies().map(candidate => candidate.id).join(', ') + throw new Error(`unknown release family ${id}; expected one of ${known}`) + } + return family +} + +/** + * The npm tarball filename `pnpm pack` writes for a member. + * @param member - the packed member. + * @returns The tarball filename. + */ +export function tarballName(member: ReleaseMember): string { + const unscoped = member.name.startsWith('@') ? member.name.slice(1).replace('/', '-') : member.name + return `${unscoped}-${member.version}.tgz` +} diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts new file mode 100644 index 0000000000..47a33a26ac --- /dev/null +++ b/scripts/release/pack.ts @@ -0,0 +1,61 @@ +/** + * Pack one release family's whole publish set into a single directory, in + * publish order, and record that order for the publish step. + * + * The pack step is the release boundary: it runs without credentials, produces + * every tarball from one commit, and hands the publish step exactly those bytes + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). + */ + +import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { releaseFamily, tarballName, type ReleaseFamily, type ReleaseMember } from './families.ts' +import { isEntry, run } from './process.ts' +import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts' + +/** Where pack output lands when `--out` is omitted. */ +const DEFAULT_OUTPUT = 'dist/npm' + +/** + * Pack one member and check what its tarball carries. + * @param family - the release family being packed. + * @param member - the member to pack. + * @param destination - absolute output directory. + * @returns The tarball filename. + */ +function packMember(family: ReleaseFamily, member: ReleaseMember, destination: string): string { + run('pnpm', ['--dir', member.directory, 'pack', '--pack-destination', destination]) + + const filename = tarballName(member) + const tarball = join(destination, filename) + if (!existsSync(tarball)) throw new Error(`${member.name} produced no tarball at ${tarball}`) + family.validatePayload(member, tarballFiles(tarball)) + return filename +} + +/** Pack the family named by `--family` into `--out`. */ +function main(): void { + const { values } = parseArgs({ + options: { family: { type: 'string' }, out: { type: 'string' } }, + allowPositionals: false, + }) + if (values.family === undefined) throw new Error('usage: pack.ts --family [--out dist/npm]') + + const family = releaseFamily(values.family) + const root = process.cwd() + const destination = resolve(root, values.out ?? DEFAULT_OUTPUT) + const members = family.publishOrder(family.members(root)) + family.verifyVersions(members) + + rmSync(destination, { recursive: true, force: true }) + mkdirSync(destination, { recursive: true }) + + const order: string[] = [] + for (const member of members) order.push(packMember(family, member, destination)) + writeFileSync(join(destination, PUBLISH_ORDER_FILE), `${order.join('\n')}\n`) + + console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`) +} + +if (isEntry(import.meta.url)) main() diff --git a/scripts/release/process.ts b/scripts/release/process.ts new file mode 100644 index 0000000000..746f24ac36 --- /dev/null +++ b/scripts/release/process.ts @@ -0,0 +1,82 @@ +/** + * Process helpers shared by the release scripts: the release steps drive `git`, + * `pnpm`, `npm`, and `tar`, and each needs one of three failure behaviours. + */ + +import { spawnSync } from 'node:child_process' +import { realpathSync } from 'node:fs' +import { fileURLToPath } from 'node:url' + +/** Where and with what environment a release step runs a command. */ +export interface RunOptions { + /** Working directory; defaults to the current one. */ + readonly cwd?: string + /** Child environment; defaults to this process's. */ + readonly env?: NodeJS.ProcessEnv +} + +/** What a command produced, for a caller that decides what a failure means. */ +export interface CommandResult { + /** Exit status, or null when a signal ended the process. */ + readonly status: number | null + /** Captured standard output. */ + readonly stdout: string + /** Captured standard error. */ + readonly stderr: string +} + +/** + * Run a command and capture its output without judging the exit status. + * @param command - executable name. + * @param args - command arguments. + * @param options - working directory and environment. + * @returns The exit status and captured streams. + */ +export function attempt(command: string, args: readonly string[], options: RunOptions = {}): CommandResult { + const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + return { status: result.status, stdout: result.stdout, stderr: result.stderr } +} + +/** + * Run a command, capture its standard output, and fail on a non-zero exit. + * @param command - executable name. + * @param args - command arguments. + * @param options - working directory and environment. + * @returns The trimmed standard output. + */ +export function capture(command: string, args: readonly string[], options: RunOptions = {}): string { + const result = attempt(command, args, options) + if (result.status !== 0) { + throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}:\n${result.stdout}\n${result.stderr}`) + } + return result.stdout.trim() +} + +/** + * Run a command with inherited streams, so its progress reaches the log, and + * fail on a non-zero exit. + * @param command - executable name. + * @param args - command arguments. + * @param options - working directory and environment. + */ +export function run(command: string, args: readonly string[], options: RunOptions = {}): void { + const result = spawnSync(command, [...args], { cwd: options.cwd, env: options.env, stdio: 'inherit' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`) +} + +/** + * Whether this module is the process entry point. + * + * The release scripts are both commands and modules: a test imports their pure + * logic, and importing a module runs its body, so an unguarded `main()` would + * run the wrong command with the wrong arguments. + * @param moduleUrl - the caller's `import.meta.url`. + * @returns True when Node started this module. + */ +export function isEntry(moduleUrl: string): boolean { + const invoked = process.argv[1] + if (invoked === undefined) return false + return realpathSync(invoked) === realpathSync(fileURLToPath(moduleUrl)) +} diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts new file mode 100644 index 0000000000..b180ce1aba --- /dev/null +++ b/scripts/release/publish.ts @@ -0,0 +1,101 @@ +/** + * Publish one packed release family from the tarballs the pack step produced. + * + * Publication is decided per package against the registry, never from a list of + * "what this release includes": a version the registry lacks is published, a + * version whose published tarball has the same integrity is skipped, and a + * version whose published tarball differs fails the run — that last case means + * the content changed without a version bump + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). + * + * Skipping on identical integrity is what makes re-running the publish step over + * the same artifact safe. + */ + +import { createHash } from 'node:crypto' +import { readFileSync } from 'node:fs' +import { join, resolve } from 'node:path' +import { parseArgs } from 'node:util' +import { releaseFamily } from './families.ts' +import { attempt, isEntry, run } from './process.ts' +import { packedIdentity, readPublishOrder } from './tarball.ts' + +/** npm access level for every package this repository publishes. */ +const ACCESS = 'restricted' + +/** What the registry knows about one version. */ +type RegistryState = + | { readonly kind: 'absent' } + | { readonly kind: 'present'; readonly integrity: string } + +/** + * The subresource integrity string npm records for a tarball. + * @param tarball - absolute tarball path. + * @returns A `sha512-` string. + */ +function integrityOf(tarball: string): string { + return `sha512-${createHash('sha512').update(readFileSync(tarball)).digest('base64')}` +} + +/** + * Ask the registry whether a version exists, and with what integrity. + * @param name - package name. + * @param version - package version. + * @returns The registry state for that version. + */ +function registryState(name: string, version: string): RegistryState { + const result = attempt('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json']) + if (result.status !== 0) { + const output = `${result.stdout}${result.stderr}` + if (output.includes('E404') || output.includes('404 Not Found')) return { kind: 'absent' } + throw new Error(`npm view ${name}@${version} failed:\n${output}`) + } + const parsed: unknown = JSON.parse(result.stdout) + if (typeof parsed !== 'string' || parsed === '') { + throw new Error(`registry reported no dist.integrity for ${name}@${version}`) + } + return { kind: 'present', integrity: parsed } +} + +/** Publish the family named by `--family` from the directory named by `--from`. */ +function main(): void { + const { values } = parseArgs({ + options: { family: { type: 'string' }, from: { type: 'string' } }, + allowPositionals: false, + }) + if (values.family === undefined || values.from === undefined) { + throw new Error('usage: publish.ts --family --from ') + } + + const family = releaseFamily(values.family) + const directory = resolve(process.cwd(), values.from) + + let published = 0 + let skipped = 0 + for (const filename of readPublishOrder(directory)) { + const tarball = join(directory, filename) + const { name, version } = packedIdentity(tarball) + const state = registryState(name, version) + if (state.kind === 'present') { + const local = integrityOf(tarball) + if (state.integrity !== local) { + throw new Error( + `${name}@${version} is already published with different content` + + `\n registry: ${state.integrity}\n packed: ${local}` + + '\nBump the version, or investigate why the build is not reproducible.', + ) + } + console.log(`release publish: ${name}@${version} already published, skipping`) + skipped += 1 + continue + } + // A prerelease version never takes the latest dist-tag. + const tagArgs = version.includes('-') ? ['--tag', 'next'] : [] + run('npm', ['publish', tarball, '--access', ACCESS, ...tagArgs]) + published += 1 + } + + console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`) +} + +if (isEntry(import.meta.url)) main() diff --git a/scripts/release/tarball.ts b/scripts/release/tarball.ts new file mode 100644 index 0000000000..568c24e877 --- /dev/null +++ b/scripts/release/tarball.ts @@ -0,0 +1,53 @@ +/** + * Reading packed npm tarballs and the order file that accompanies them. + * + * The release steps after pack treat a directory of tarballs as the unit of + * work, so they read what a tarball declares rather than what the checkout + * currently says. + */ + +import { readFileSync } from 'node:fs' +import { join } from 'node:path' +import { capture } from './process.ts' + +/** Name of the file recording the order in which a packed family uploads. */ +export const PUBLISH_ORDER_FILE = 'publish-order.txt' + +/** What a packed tarball calls itself. */ +export interface PackedIdentity { + /** Package name from the packed manifest. */ + readonly name: string + /** Package version from the packed manifest. */ + readonly version: string +} + +/** + * List a tarball's members. + * @param tarball - absolute tarball path. + * @returns Every path inside the archive. + */ +export function tarballFiles(tarball: string): string[] { + return capture('tar', ['-tzf', tarball]).split('\n').filter(line => line !== '') +} + +/** + * Read a packed tarball's own manifest. + * @param tarball - absolute tarball path. + * @returns The name and version the tarball declares. + */ +export function packedIdentity(tarball: string): PackedIdentity { + const manifest: unknown = JSON.parse(capture('tar', ['-xOzf', tarball, 'package/package.json'])) + if (manifest === null || typeof manifest !== 'object') throw new Error(`${tarball} has no manifest`) + const { name, version } = manifest as Record + if (typeof name !== 'string' || typeof version !== 'string') throw new Error(`${tarball} manifest lacks name/version`) + return { name, version } +} + +/** + * Read a packed directory's upload order. + * @param directory - absolute path of a pack output directory. + * @returns Tarball filenames in upload order. + */ +export function readPublishOrder(directory: string): string[] { + return readFileSync(join(directory, PUBLISH_ORDER_FILE), 'utf8').split('\n').filter(line => line !== '') +} diff --git a/scripts/release/verify-packed-install.ts b/scripts/release/verify-packed-install.ts new file mode 100644 index 0000000000..29ec7b851e --- /dev/null +++ b/scripts/release/verify-packed-install.ts @@ -0,0 +1,120 @@ +/** + * Install packed tarballs into a throwaway consumer outside the repository and + * drive the installed executable with plain Node. + * + * Every tarball the installed tree needs comes from `--from`, so the only + * registry traffic is for external dependencies. That matters beyond hermetic + * verification: the harness packages declare the vendored framework as a peer, + * and those packages live in another release sequence that this credential-free + * job cannot fetch from a private registry — so a dsh verification passes the + * vendored family's pack output too, while publishing only its own + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). + * + * What this proves is that `files` selected a complete payload and that the + * published dependency ranges resolve. A workspace link or a stale `lib/` in the + * checkout cannot stand in for a missing file here. + */ + +import { mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join, resolve } from 'node:path' +import { pathToFileURL } from 'node:url' +import { parseArgs } from 'node:util' +import { releaseFamily } from './families.ts' +import { capture, isEntry } from './process.ts' +import { packedIdentity } from './tarball.ts' + +/** + * Environment for the installed artifact: no host Node hooks, no host DeepSeek + * Harness home, and no ambient npm user agent that would confuse npm. + * @param consumerRoot - the throwaway consumer directory. + * @returns The child environment. + */ +function consumerEnvironment(consumerRoot: string): NodeJS.ProcessEnv { + const environment = { ...process.env } + delete environment.npm_config_user_agent + delete environment.NPM_CONFIG_USER_AGENT + delete environment.NODE_OPTIONS + delete environment.NODE_PATH + environment.DSH_HOME = resolve(consumerRoot, '.dsh') + environment.DSH_AGENTS_HOME = resolve(consumerRoot, '.agents') + environment.DSH_TELEMETRY_DISABLED = '1' + return environment +} + +/** + * Every packed tarball in the given directories, as `file:` dependency entries. + * + * The directories are read by their contents rather than a pack order file: a + * directory here can hold tarballs packed only to satisfy a cross-sequence + * dependency, which no release order describes. + * @param directories - absolute directories holding packed tarballs. + * @returns Package name to tarball file URL, and the version each carries. + */ +function packedDependencies(directories: readonly string[]): Map { + const dependencies = new Map() + for (const directory of directories) { + const tarballs = readdirSync(directory).filter(name => name.endsWith('.tgz')).sort() + if (tarballs.length === 0) throw new Error(`${directory} holds no packed tarball`) + for (const filename of tarballs) { + const tarball = join(directory, filename) + const { name, version } = packedIdentity(tarball) + dependencies.set(name, { url: pathToFileURL(tarball).href, version }) + } + } + return dependencies +} + +/** Install every tarball under `--from` and drive the `--family` entry. */ +function main(): void { + const { values } = parseArgs({ + options: { family: { type: 'string' }, from: { type: 'string', multiple: true } }, + allowPositionals: false, + }) + if (values.family === undefined || values.from === undefined || values.from.length === 0) { + throw new Error('usage: verify-packed-install.ts --family --from [--from ...]') + } + + const family = releaseFamily(values.family) + const entry = family.installedEntry + if (entry === undefined) { + console.log(`release verify-packed-install: family ${family.id} publishes no executable, nothing to drive`) + return + } + + const root = process.cwd() + const packed = packedDependencies(values.from.map(directory => resolve(root, directory))) + const expected = packed.get(entry.packageName) + if (expected === undefined) throw new Error(`${entry.packageName} is not among the packed tarballs`) + + const consumerRoot = mkdtempSync(join(tmpdir(), `dsh-packed-${family.id}-`)) + try { + writeFileSync(join(consumerRoot, 'package.json'), `${JSON.stringify({ + name: `dsh-packed-install-${family.id}`, + version: '0.0.0', + private: true, + dependencies: Object.fromEntries([...packed].map(([name, entryPacked]) => [name, entryPacked.url])), + }, null, 2)}\n`) + + const environment = consumerEnvironment(consumerRoot) + console.log(`release verify-packed-install: installing ${String(packed.size)} tarball(s) into ${consumerRoot}`) + // Optional dependencies are omitted: the Landlock platform packages behind + // them need a musl toolchain and one build per architecture, and a consumer + // that cannot install them must still start — which is what optional means + // here. Their entry package is a plain dependency of dsh-sandbox-local, so + // its tarball is supplied through --from. + capture('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false', '--omit=optional'], + { cwd: consumerRoot, env: environment }) + + const bin = join(consumerRoot, 'node_modules', ...entry.packageName.split('/'), entry.binPath) + const version = capture(process.execPath, [bin, '--version'], { cwd: consumerRoot, env: environment }) + if (version !== expected.version) { + throw new Error(`installed ${entry.packageName} --version reported ${JSON.stringify(version)}, expected ${expected.version}`) + } + console.log(`release verify-packed-install: installed ${entry.packageName} reports ${version}`) + } finally { + rmSync(consumerRoot, { recursive: true, force: true }) + } +} + +if (isEntry(import.meta.url)) main() diff --git a/scripts/release/verify.ts b/scripts/release/verify.ts new file mode 100644 index 0000000000..1bd74c84d6 --- /dev/null +++ b/scripts/release/verify.ts @@ -0,0 +1,70 @@ +/** + * Verify a release family's version baseline, and — when publishing — that the + * run comes from the family's tag and its members are publishable. + * + * Publication happens only from GitHub Actions, so the tag and publishability + * checks are gates on the workflow, not advisory local warnings + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). + */ + +import { parseArgs } from 'node:util' +import { isEntry } from './process.ts' +import { releaseFamily, type ReleaseFamily, type ReleaseMember } from './families.ts' + +/** + * Assert every member may be published: npm refuses a `private` package. + * @param members - the family's members. + */ +function verifyPublishable(members: readonly ReleaseMember[]): void { + const priv = members.filter(member => member.manifest.private === true) + if (priv.length > 0) { + throw new Error(`publishing requires removing "private": true from:\n${priv.map(member => member.directory).join('\n')}`) + } +} + +/** + * Assert the workflow runs from a tag this family publishes from, and that the + * tag names a version the family actually carries. + * @param family - the release family. + * @param members - the family's members. + * @param ref - the `GITHUB_REF` value. + */ +function verifyTag(family: ReleaseFamily, members: readonly ReleaseMember[], ref: string): void { + const prefix = 'refs/tags/' + if (!ref.startsWith(prefix)) { + throw new Error(`publishing release family ${family.id} requires running from a ${family.tagPrefix}* tag, got ${ref || '(no ref)'}`) + } + const tag = ref.slice(prefix.length) + if (!tag.startsWith(family.tagPrefix)) { + throw new Error(`tag ${tag} does not belong to release family ${family.id} (expected ${family.tagPrefix}*)`) + } + const expected = members.map(member => family.tagFor(member)) + if (!expected.includes(tag)) { + throw new Error(`tag ${tag} names no version this family carries; its members would tag as:\n${[...new Set(expected)].join('\n')}`) + } +} + +/** Run the verification for the family named by `--family`. */ +function main(): void { + const { values } = parseArgs({ + options: { family: { type: 'string' } }, + allowPositionals: false, + }) + if (values.family === undefined) throw new Error('usage: verify.ts --family ') + + const family = releaseFamily(values.family) + const members = family.members(process.cwd()) + family.verifyVersions(members) + + const publishing = process.env.RELEASE_PUBLISH === 'true' + if (publishing) { + verifyPublishable(members) + verifyTag(family, members, process.env.GITHUB_REF ?? '') + } + + const versions = [...new Set(members.map(member => member.version))] + const summary = versions.length === 1 ? versions[0] : `${String(versions.length)} versions` + console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}${publishing ? ', publish gates passed' : ''}`) +} + +if (isEntry(import.meta.url)) main() diff --git a/scripts/rescope-vendor.spec.ts b/scripts/rescope-vendor.spec.ts new file mode 100644 index 0000000000..563be68dfa --- /dev/null +++ b/scripts/rescope-vendor.spec.ts @@ -0,0 +1,41 @@ +/** + * Acceptance-path coverage for the rescope codemod's exact-edit classifier: a + * duplicated insertion — what a non-idempotent apply produces — must be + * rejected rather than applied again. + */ + +import { describe, expect, it } from 'vitest' +import { exactEditState } from './rescope-vendor.ts' + +const ANCHOR = '\n## Sync procedure' +const INSERTED = `\n15. **rescope**: one log entry.\n${ANCHOR}` + +describe('exactEditState', () => { + it('classifies an insertion by its target form, so a duplicate is invalid', () => { + expect(exactEditState(`log\n${ANCHOR}\n`, ANCHOR, INSERTED, 1)).toBe('pending') + expect(exactEditState(`log${INSERTED}\n`, ANCHOR, INSERTED, 1)).toBe('applied') + // The anchor survives an insertion, so counting the source form would have + // called this pending and inserted the entry a second time. + expect(exactEditState(`log${INSERTED}${INSERTED}\n`, ANCHOR, INSERTED, 1)).toBe('invalid') + expect(exactEditState('log\n', ANCHOR, INSERTED, 1)).toBe('invalid') + }) + + it('classifies a deletion by its source form, and requires its remainder to survive', () => { + const remainder = 'exclude:\n' + const withEntries = 'exclude:\n - cordis@4\n' + expect(exactEditState(withEntries, withEntries, remainder, 1)).toBe('pending') + expect(exactEditState(remainder, withEntries, remainder, 1)).toBe('applied') + // Upstream dropped the whole field: the source form is gone, but so is the + // remainder, so this is a moved site rather than a completed deletion. + expect(exactEditState('unrelated:\n', withEntries, remainder, 1)).toBe('invalid') + }) + + it('requires a replacement to leave no source form and the exact target count', () => { + expect(exactEditState('a = 1\n', 'a = 1', 'b = 2', 1)).toBe('pending') + expect(exactEditState('b = 2\n', 'a = 1', 'b = 2', 1)).toBe('applied') + expect(exactEditState('b = 2\nb = 2\n', 'a = 1', 'b = 2', 1)).toBe('invalid') + // A moved or partially applied site: neither state is complete. + expect(exactEditState('a = 1\nb = 2\n', 'a = 1', 'b = 2', 1)).toBe('invalid') + expect(exactEditState('x\n', 'a = 1', 'b = 2', 1)).toBe('invalid') + }) +}) diff --git a/scripts/rescope-vendor.ts b/scripts/rescope-vendor.ts new file mode 100644 index 0000000000..bca3183795 --- /dev/null +++ b/scripts/rescope-vendor.ts @@ -0,0 +1,771 @@ +/** + * Rescope the vendored Cordis packages into the `@deepseek-ai` scope, and undo + * that rescope with `--reverse`. Every harness package declares `cordis` as a + * peer dependency, so publication carries this framework layer too; publishing + * it under the upstream names would squat them on the registry + * ([rationale](../.agents/notes/implemented/process/2026-08-10-vendor-package-rescope.md), + * [name mapping](../docs/rescope.md)). + * + * The generic pass rewrites ONLY delimited, complete package-name tokens: + * `'old'` / `"old"` / `` `old` `` / `'old/subpath'`, plus a YAML `name: old` + * scalar. A match needs a quote (or `name: `) immediately left and the matching + * quote — optionally after a `/subpath` — immediately right, which excludes + * `cordis.yml`, the Loader's `cordis:` builtin prefix, `cordis-config-entry`, + * `@deepseek-ai/dsh-tool-cordis`, and `cordiverse/cordis`, and makes the + * rewrite idempotent because the scoped name's `cordis` is preceded by `/`. + * Markdown follows the rename inside every fence, and in `docs/` prose too: + * a tutorial that teaches an unresolvable name is wrong, while prose elsewhere + * records what was true when it was written. + * + * Sites the token rule cannot express (dot-notation access, unquoted object + * keys, regex literals, the vendored-manifest table) are listed in + * {@link EXACT_EDITS} with an exact hit count, so an upstream change to one of + * them fails loudly instead of being silently skipped. + * + * Usage: `pnpm run rescope-vendor [--apply|--check] [--reverse]`. Without a + * mode it reports what would change. `--check` asserts the post-state: no + * residue, every exact edit landed, every postcondition holds, and a second + * `--apply` would be a no-op. + */ + +import { execFileSync } from 'node:child_process' +import { existsSync, readFileSync, realpathSync, writeFileSync } from 'node:fs' +import { resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const root = resolve(import.meta.dirname, '..') + +/** One vendored package's directory, upstream npm name, and rescoped name. */ +interface Rename { + readonly directory: string + readonly upstream: string + readonly scoped: string +} + +/** The mapping this codemod applies; `vendor/README.md` carries the same table. */ +const RENAMES: readonly Rename[] = [ + { directory: 'cordis', upstream: 'cordis', scoped: '@deepseek-ai/cordis' }, + { directory: 'cosmokit', upstream: 'cosmokit', scoped: '@deepseek-ai/cosmokit' }, + { directory: 'schemastery', upstream: 'schemastery', scoped: '@deepseek-ai/schemastery' }, + { directory: 'loader', upstream: '@cordisjs/plugin-loader', scoped: '@deepseek-ai/cordis-plugin-loader' }, + { directory: 'include', upstream: '@cordisjs/plugin-include', scoped: '@deepseek-ai/cordis-plugin-include' }, + { directory: 'group', upstream: '@cordisjs/plugin-group', scoped: '@deepseek-ai/cordis-plugin-group' }, + { directory: 'timer', upstream: '@cordisjs/plugin-timer', scoped: '@deepseek-ai/cordis-plugin-timer' }, + { directory: 'hmr', upstream: '@cordisjs/plugin-hmr', scoped: '@deepseek-ai/cordis-plugin-hmr' }, + { directory: 'logger-console', upstream: '@cordisjs/plugin-logger-console', scoped: '@deepseek-ai/cordis-plugin-logger-console' }, +] + +const EXTENSIONS = ['.ts', '.tsx', '.js', '.mjs', '.cjs', '.tpl', '.json', '.yml', '.yaml', '.md'] as const + +/** An exact-string edit the token rule cannot express, with its required hit count. */ +interface ExactEdit { + readonly id: string + readonly file: string + readonly find: string + readonly replace: string + readonly expect: number +} + +/** + * A file where an upstream name also appears as a vendor DIRECTORY name or an + * upstream runtime identifier: the generic pass is disabled for the listed + * names and {@link EXACT_EDITS} renames the real package-name occurrences. + */ +interface GenericSkip { + readonly file: string + readonly upstream: readonly string[] +} + +const GENERIC_SKIPS: readonly GenericSkip[] = [ + // `vendorPackages` lists vendor/ directory names, joined with 'vendor' below it. + { file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', upstream: ['cordis', 'cosmokit', 'schemastery'] }, + // Mixes join(root, 'vendor', 'cordis') paths with real manifest names. + { file: 'packages/scaffold/helper/tests/documents.spec.ts', upstream: ['cordis'] }, + // `Symbol.for('schemastery')` and the `vendor:` metadata field are upstream identifiers. + { file: 'vendor/schemastery/src/index.ts', upstream: ['schemastery'] }, + // Asserts the vendored-manifest table, which gains an upstream-name column. + { file: 'scripts/gen-third-party-notices.spec.ts', upstream: RENAMES.map(rename => rename.upstream) }, + // `cordis` is also an agent-preset id — the directory name under + // apps/cli/config/agent-presets/ — so in these files the bare name is + // product data, not a package reference. Renaming it changed which preset + // the creator flow stages and which id the roster reports. + { file: 'packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/src/client/index.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', upstream: ['cordis'] }, + { file: 'packages/client/ui-agent-preset/tests/section.spec.tsx', upstream: ['cordis'] }, + { file: 'apps/cli/tests/web-agent-presets.e2e.ts', upstream: ['cordis'] }, + { file: 'apps/web/tests/agent-preset-authoring.e2e.ts', upstream: ['cordis'] }, + { file: 'packages/preset/agent-presets/tests/session.spec.ts', upstream: ['cordis'] }, + // The preset's own composition: its header comment and its system prompt name + // the preset a model mounts, so the scoped name would send the model after an + // id no roster reports. + { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', upstream: ['cordis'] }, + // GROUP_ORDER holds `packages//` directory names, not package names. + { file: 'scripts/gen-module-graph.ts', upstream: ['cordis'] }, + { file: 'scripts/gen-doc-graphs.ts', upstream: ['cordis'] }, +] + +/** A string that must appear exactly `count` times once the rescope has run. */ +interface PostCondition { + readonly file: string + readonly text: string + readonly count: number +} + +const POSTCONDITIONS: readonly PostCondition[] = [ + { file: 'vendor/cordis/package.json', text: '"name": "@deepseek-ai/cordis"', count: 1 }, + { file: 'vendor/hmr/package.json', text: '"name": "@deepseek-ai/cordis-plugin-hmr"', count: 1 }, + { file: 'scripts/cordis-walk.ts', text: '@deepseek-ai\\/cordis', count: 1 }, + { file: 'scripts/cordis-walk.ts', text: '!== \'@deepseek-ai/cordis\'', count: 1 }, + { file: 'scripts/gen-scoped-events.ts', text: '=== \'@deepseek-ai/cordis\'', count: 1 }, + { file: 'packages/typert/generator/src/analyzer.ts', text: '!== \'@deepseek-ai/cordis\'', count: 2 }, + { file: 'scripts/check-workspace-constraints.ts', text: '?.[\'@deepseek-ai/cordis\']', count: 2 }, + { file: 'packages/scaffold/helper/src/project/npm-dependency-policy.ts', text: '\'@deepseek-ai/cordis\': \'^4.0.0-rc.7\'', count: 1 }, + { file: 'packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts', text: '\'@deepseek-ai/cordis\': cordisSpec', count: 2 }, + { file: 'packages/boot/app-boot/tsdown.config.ts', text: '[\'@deepseek-ai/cordis-plugin-include\']', count: 1 }, + { file: 'tsconfig.base.json', text: '"@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"]', count: 1 }, + // One insertion, once: a duplicated log entry is what a non-idempotent apply produced. + { file: 'vendor/README.md', text: '17. **`@deepseek-ai` rescope**', count: 1 }, + { file: 'knip.json', text: '@cordisjs', count: 0 }, + { file: 'pnpm-workspace.yaml', text: 'cordis@4.0.0-rc.7', count: 0 }, + // The preset ids in this table are product data, not package names. + { file: 'packages/client/ui-agent-preset/tests/locales.spec.ts', text: '[\'cordis\', \'presetCordisName\'', count: 1 }, + // The preset id the shipped composition documents to its own model. + { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'The `cordis` agent preset', count: 1 }, + { file: 'apps/cli/config/agent-presets/cordis/agent.cordis.yml', text: 'corrupting the `cordis` preset', count: 1 }, + // The vendor-directory paths in these fixtures must survive the rename. + { file: 'packages/scaffold/helper/tests/documents.spec.ts', text: 'join(root, \'vendor\', \'cordis\')', count: 2 }, + { file: 'packages/examples/acp-demo/tests/built-bin.e2e.ts', text: '\'cordis\', \'loader\', \'include\', \'timer\', \'hmr\', \'logger-console\',', count: 1 }, +] + +/** + * Every exact edit, in application order. Each `find` is written against the + * PRE-rename text because these run before the generic pass, so no `find` may + * quote a neighbouring line the generic pass would rewrite. + */ +const EXACT_EDITS: readonly ExactEdit[] = [ + { + id: 'cordis-walk-merge-head', + file: 'scripts/cordis-walk.ts', + find: 'const MERGE_HEAD = /declare module [\'"](?:cordis|\\.\\/context\\.ts)[\'"]/', + replace: 'const MERGE_HEAD = /declare module [\'"](?:@deepseek-ai\\/cordis|\\.\\/context\\.ts)[\'"]/', + expect: 1, + }, + { + id: 'constraints-manifest-lookup', + file: 'scripts/check-workspace-constraints.ts', + find: ` const peer = manifest.peerDependencies?.cordis + const dev = manifest.devDependencies?.cordis + + if (!peer) errors.push(\`\${label}: cordis must be a peerDependency\`) + if (!dev) errors.push(\`\${label}: cordis must also be a devDependency\`) + if (peer && dev && peer !== dev) { + errors.push(\`\${label}: cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`, + replace: ` const peer = manifest.peerDependencies?.['@deepseek-ai/cordis'] + const dev = manifest.devDependencies?.['@deepseek-ai/cordis'] + + if (!peer) errors.push(\`\${label}: @deepseek-ai/cordis must be a peerDependency\`) + if (!dev) errors.push(\`\${label}: @deepseek-ai/cordis must also be a devDependency\`) + if (peer && dev && peer !== dev) { + errors.push(\`\${label}: @deepseek-ai/cordis peer (\${peer}) and dev (\${dev}) ranges must match\`)`, + expect: 1, + }, + { + id: 'scaffold-dependency-policy', + file: 'packages/scaffold/helper/src/project/npm-dependency-policy.ts', + find: ' cordis: \'^4.0.0-rc.7\',', + replace: ' \'@deepseek-ai/cordis\': \'^4.0.0-rc.7\',', + expect: 1, + }, + { + id: 'scaffold-plugin-blueprint', + file: 'packages/scaffold/helper/src/plugins/local-plugin-blueprint.ts', + find: ` cordis: cordisSpec, + }, + devDependencies: { + cordis: cordisSpec, + },`, + replace: ` '@deepseek-ai/cordis': cordisSpec, + }, + devDependencies: { + '@deepseek-ai/cordis': cordisSpec, + },`, + expect: 1, + }, + { + id: 'scaffold-link-workspace-lookup', + file: 'packages/scaffold/create-sdk/tests/link-workspace.e2e.ts', + find: 'manifest.dependencies.cordis', + replace: 'manifest.dependencies[\'@deepseek-ai/cordis\']', + expect: 1, + }, + { + id: 'documents-spec-manifest-name', + file: 'packages/scaffold/helper/tests/documents.spec.ts', + find: 'JSON.stringify({ name: \'cordis\' })', + replace: 'JSON.stringify({ name: \'@deepseek-ai/cordis\' })', + expect: 1, + }, + { + id: 'documents-spec-peer-key', + file: 'packages/scaffold/helper/tests/documents.spec.ts', + find: 'peerDependencies: { cordis: \'^4\' },', + replace: 'peerDependencies: { \'@deepseek-ai/cordis\': \'^4\' },', + expect: 1, + }, + { + id: 'documents-spec-closure-order', + file: 'packages/scaffold/helper/tests/documents.spec.ts', + find: ' \'@deepseek-ai/dsh-helper\', \'@deepseek-ai/dsh-scripts\', \'cordis\',', + replace: ' \'@deepseek-ai/cordis\', \'@deepseek-ai/dsh-helper\', \'@deepseek-ai/dsh-scripts\',', + expect: 1, + }, + { + id: 'documents-spec-lookups', + file: 'packages/scaffold/helper/tests/documents.spec.ts', + find: ` expect(manifest.npmDependency('cordis')?.spec).toMatch(/^link:/) + expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false') + expect(workspace.packageDirectory('cordis')).toBe(join(root, 'vendor', 'cordis')) + expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('cordis')`, + replace: ` expect(manifest.npmDependency('@deepseek-ai/cordis')?.spec).toMatch(/^link:/) + expect(pnpmWorkspace.serialize()).toContain('autoInstallPeers: false') + expect(workspace.packageDirectory('@deepseek-ai/cordis')).toBe(join(root, 'vendor', 'cordis')) + expect(await readFile(join(root, 'vendor', 'cordis', 'package.json'), 'utf8')).toContain('@deepseek-ai/cordis')`, + expect: 1, + }, + { + id: 'documents-spec-policy-lookup', + file: 'packages/scaffold/helper/tests/documents.spec.ts', + find: ' expect(resolveNpmDependency(\'cordis\', \'devDependencies\', \'0.0.1\')).toEqual({', + replace: ' expect(resolveNpmDependency(\'@deepseek-ai/cordis\', \'devDependencies\', \'0.0.1\')).toEqual({', + expect: 1, + }, + { + // The rescoped name is already covered by the `@deepseek-ai/.+` pattern beside it. + id: 'knip-logger-console', + file: 'knip.json', + find: ` "ignoreDependencies": [ + "@cordisjs/plugin-logger-console", + "@deepseek-ai/.+" + ] + }, + "packages/util/home": {`, + replace: ` "ignoreDependencies": [ + "@deepseek-ai/.+" + ] + }, + "packages/util/home": {`, + expect: 1, + }, + { + id: 'knip-bundle-base', + file: 'knip.json', + find: ` "packages/bundle/base": { + "ignoreDependencies": [ + "@deepseek-ai/.+", + "@cordisjs/.+" + ]`, + replace: ` "packages/bundle/base": { + "ignoreDependencies": [ + "@deepseek-ai/.+" + ]`, + expect: 1, + }, + { + // Rescoped packages are never fetched from a registry, so the exclusion is dead config. + id: 'pnpm-release-age', + file: 'pnpm-workspace.yaml', + find: `minimumReleaseAgeExclude: + # Cordis release candidates are source-vendored and pinned in vendor/README.md + # during the same-day sync that updates package manifests and the lockfile. + - '@cordisjs/plugin-loader@1.0.0-rc.5' + - cordis@4.0.0-rc.7 +`, + replace: 'minimumReleaseAgeExclude:\n', + expect: 1, + }, + { + id: 'publication-set-scope-assertion', + file: 'scripts/publish-npm-baseline.ts', + find: ' if (!isVendored && !name.startsWith(\'@deepseek-ai/\')) {', + replace: ` // Vendored packages are rescoped too (vendor/README.md), so publication + // never carries an upstream name that would squat it on the registry. + if (!name.startsWith('@deepseek-ai/')) {`, + expect: 1, + }, + { + id: 'vendor-readme-preamble', + file: 'vendor/README.md', + find: '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.', + replace: 'All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`.', + expect: 1, + }, + { + id: 'vendor-readme-schemastery-note', + file: 'vendor/README.md', + find: 'whose lazy `require(\'cosmokit\')` can race', + replace: 'whose lazy `require(\'@deepseek-ai/cosmokit\')` can race', + expect: 1, + }, + { + id: 'vendor-readme-table-head', + file: 'vendor/README.md', + find: '| Directory | npm name | Version | Upstream repo | Commit |\n|---|---|---|---|---|', + replace: '| Directory | npm name | Upstream name | Version | Upstream repo | Commit |\n|---|---|---|---|---|---|', + expect: 1, + }, + { + id: 'vendor-readme-local-modification-log', + file: 'vendor/README.md', + find: '\n## Sync procedure', + replace: '17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table\'s `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for(\'schemastery\')` and Schemastery\'s `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table\'s two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md).\n\n## Sync procedure', + expect: 1, + }, + { + // A plain fence listing the bundle's mounted tree: a bare token, no quotes. + id: 'agent-spine-demo-mounted-tree', + file: 'packages/examples/agent-spine-demo/README.md', + find: '@cordisjs/plugin-timer timer service', + replace: '@deepseek-ai/cordis-plugin-timer timer service', + expect: 1, + }, + { + id: 'agent-spine-demo-mounted-tree-zh', + file: 'packages/examples/agent-spine-demo/README.zh.md', + find: '@cordisjs/plugin-timer timer service', + replace: '@deepseek-ai/cordis-plugin-timer timer service', + expect: 1, + }, + { + // The root contract claimed vendored packages keep their upstream names. + id: 'root-agents-vendored-name-contract', + file: 'AGENTS.md', + find: 'vendored packages keep upstream names and are `private: true`. `cordis` is a peerDependency (+ dev) of every harness package.', + replace: 'vendored packages are rescoped ([mapping](docs/rescope.md)) and `private: true`. `@deepseek-ai/cordis` is a peerDependency (+ dev) of every harness package.', + expect: 1, + }, + { + // The client purity gate reads `@deepseek-ai/` as "another plugin package". + // The rescope moves the vendored framework and its libraries into that + // namespace, where the gate would reject the library imports client + // bundles have always inlined, so it needs their names. + id: 'client-purity-vendored-libraries', + file: 'packages/client/tsdown.client.ts', + find: '/** Generated descriptor/codec contribution with no shared runtime identity. */', + replace: `/** + * Vendored framework libraries: rescoped into @deepseek-ai, so the gate below + * would read them as plugin packages. They carry no cross-plugin runtime + * identity to share — the framework itself is a platform module (external), + * while these are ordinary libraries a browser bundle inlines. + */ +const VENDORED_LIBRARY = /^@deepseek-ai\\/(cosmokit|schemastery)(\\/|$)/ + +/** Generated descriptor/codec contribution with no shared runtime identity. */`, + expect: 1, + }, + { + id: 'client-purity-vendored-libraries-predicate', + file: 'packages/client/tsdown.client.ts', + find: ' if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point', + replace: ` if (VENDORED_LIBRARY.test(source)) return null // vendored library: inline, no shared identity + if (INLINE_SAFE.test(source) || GENERATED_REMOTE.test(source)) return null // wire contribution: inline is the point`, + expect: 1, + }, + { + // The step-1 file tree told the reader to keep the upstream name, one + // paragraph above the invariant that says to rescope it. + id: 'vendoring-cookbook-tree-comment', + file: 'docs/cookbook/adding-a-vendored-package.md', + find: ' package.json # from upstream; set "private": true, keep name/exports/type', + replace: ' package.json # from upstream; set "private": true, rescope the name, keep exports/type', + expect: 1, + }, + { + id: 'vendoring-cookbook-tree-comment-zh', + file: 'docs/cookbook/adding-a-vendored-package.zh.md', + find: ' package.json # from upstream; set "private": true, keep name/exports/type', + replace: ' package.json # from upstream; set "private": true, rescope the name, keep exports/type', + expect: 1, + }, + { + // The checklist told the next vendoring to keep upstream's name. + id: 'vendoring-cookbook-name-invariant', + file: 'docs/cookbook/adding-a-vendored-package.md', + find: "keep upstream's `name`/`version`/`exports`/`type`", + replace: "rescope the `name` ([mapping](../rescope.md)) while keeping upstream's `version`/`exports`/`type`", + expect: 1, + }, + { + id: 'vendoring-cookbook-name-invariant-zh', + file: 'docs/cookbook/adding-a-vendored-package.zh.md', + find: '保留上游的 `name`/`version`/`exports`/`type`', + replace: '改写 `name` 的 scope([映射](../rescope.md)),保留上游的 `version`/`exports`/`type`', + expect: 1, + }, + { + // The real package references in files whose other `cordis` strings are preset ids. + id: 'agent-preset-spec-framework-import', + file: 'packages/client/ui-agent-preset/tests/apply.spec.ts', + find: "import { Context } from 'cordis'", + replace: "import { Context } from '@deepseek-ai/cordis'", + expect: 1, + }, + { + id: 'web-agent-presets-e2e-framework-import', + file: 'apps/cli/tests/web-agent-presets.e2e.ts', + find: "import { Context } from 'cordis'", + replace: "import { Context } from '@deepseek-ai/cordis'", + expect: 1, + }, + { + id: 'notices-vendored-row-type', + file: 'scripts/gen-third-party-notices.ts', + find: `export interface VendoredRow { + npmName: string + upstream: string +}`, + replace: `export interface VendoredRow { + npmName: string + /** The name this package carries upstream; MIT attribution names the fork's origin, not our scope. */ + upstreamName: string + upstream: string +}`, + expect: 1, + }, + { + id: 'notices-vendored-row-parse', + file: 'scripts/gen-third-party-notices.ts', + find: ` const match = /^\\| \\x60\\S+\\/\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\S+ \\| (https:\\/\\/\\S+?)(?: \\([^)]*\\))? \\| \\x60[0-9a-f]+\\x60 \\|$/.exec(line) + if (match === null) continue + const [, npmName, upstream] = match + if (npmName === undefined || upstream === undefined) continue + rows.push({ npmName, upstream })`, + replace: ` const match = new RegExp(String.raw\`^\\| \\x60\\S+\\/\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\x60([^\\x60]+)\\x60 \\| \\S+ \\| \` + + String.raw\`(https:\\/\\/\\S+?)(?: \\([^)]*\\))? \\| \\x60[0-9a-f]+\\x60 \\|$\`).exec(line) + if (match === null) continue + const [, npmName, upstreamName, upstream] = match + if (npmName === undefined || upstreamName === undefined || upstream === undefined) continue + rows.push({ npmName, upstreamName, upstream })`, + expect: 1, + }, + { + id: 'notices-vendored-section', + file: 'scripts/gen-third-party-notices.ts', + find: 'The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm. All are MIT-licensed', + replace: 'The Cordis framework and its foundation libraries are source-vendored into this repository rather than consumed from npm, and republished under the \\`@deepseek-ai\\` scope. All are MIT-licensed', + expect: 1, + }, + { + id: 'notices-vendored-table', + file: 'scripts/gen-third-party-notices.ts', + find: `| Package | Upstream | License | +| --- | --- | --- | +\${vendored.map(row => \`| \\\`\${row.npmName}\\\` | [\${row.upstream.replace('https://', '')}](\${row.upstream}) | MIT |\`).join('\\n')}`, + replace: `| Package | Upstream name | Upstream | License | +| --- | --- | --- | --- | +\${vendored.map(row => \`| \\\`\${row.npmName}\\\` | \\\`\${row.upstreamName}\\\` | [\${row.upstream.replace('https://', '')}](\${row.upstream}) | MIT |\`).join('\\n')}`, + expect: 1, + }, + { + id: 'notices-spec-row-fixture', + file: 'scripts/gen-third-party-notices.spec.ts', + find: ' expect(rows).toContainEqual({ npmName: \'cordis\', upstream: \'https://github.com/cordiverse/cordis\' })', + replace: ` expect(rows).toContainEqual({ + npmName: '@deepseek-ai/cordis', + upstreamName: 'cordis', + upstream: 'https://github.com/cordiverse/cordis', + })`, + expect: 1, + }, + { + id: 'notices-spec-shape-fixture', + file: 'scripts/gen-third-party-notices.spec.ts', + find: 'parseVendoredRows(\'| `cordis/` | cordis | 4.0.0 | https://example.com | `abc123` |\\n\')', + replace: 'parseVendoredRows(\'| `cordis/` | `@deepseek-ai/cordis` | cordis | 4.0.0 | https://example.com | `abc123` |\\n\')', + expect: 1, + }, + { + // The framework peer is no longer a registry name, so the rehearsal must install this + // repository's vendored copies; cosmokit comes along as cordis's own dependency. + id: 'packed-install-vendored-peer', + file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts', + find: ` 'packages/support/invariants', +]`, + replace: ` 'packages/support/invariants', + // The framework and the vendored packages the closure declares outright: + // rescoped into @deepseek-ai, so the consumer installs this repository's + // copies. Schemastery is a hard dependency of three members above, not a + // peer, so npm resolves it while installing them. + 'vendor/cordis', + 'vendor/cosmokit', + 'vendor/schemastery', +]`, + expect: 1, + }, + { + id: 'packed-install-registry-spec', + file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts', + find: ` // Peer ranges resolve to the tarballs; Cordis is pinned to their peer range. Do not omit optional + // dependencies because the launcher selects its OS/CPU package through one. + writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' })) + const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs, 'cordis@4.0.0-rc.7'], {`, + replace: ` // Peer ranges resolve to the tarballs, the framework peer included. Do not omit optional + // dependencies because the launcher selects its OS/CPU package through one. + writeFileSync(join(consumerDir, 'package.json'), JSON.stringify({ name: 'dsh-packed-consumer', private: true, type: 'module' })) + const install = spawnSync('npm', ['install', '--no-audit', '--no-fund', ...tarballs], {`, + expect: 1, + }, + { + id: 'packed-install-module-doc', + file: 'packages/sandbox/sandbox-local/tests/packed-install.e2e.ts', + find: ` * Keyless publish-path rehearsal. It packs the provider, its workspace peers, and the current + * repository's Landlock entry/platform packages, then installs those exact tarballs in an external + * plain-Node consumer. The host launcher comes from the exact local tarballs, so no registry copy, + * tsx, path mapping, or workspace resolution can hide missing files, dependency errors, or lost + * executable modes.`, + replace: ` * Keyless publish-path rehearsal. It packs the provider, its workspace peers, the vendored framework + * peer, and the current repository's Landlock entry/platform packages, then installs those exact + * tarballs in an external plain-Node consumer. The host launcher comes from the exact local tarballs, + * so no registry copy, tsx, path mapping, or workspace resolution can hide missing files, dependency + * errors, or lost executable modes.`, + expect: 1, + }, + // The manifest table's name column plus the new upstream-name column, one edit per row. + ...RENAMES.map(rename => ({ + id: `vendor-readme-row-${rename.directory}`, + file: 'vendor/README.md', + find: `| \`${rename.directory}/\` | \`${rename.upstream}\` | `, + replace: `| \`${rename.directory}/\` | \`${rename.scoped}\` | \`${rename.upstream}\` | `, + expect: 1, + })), +] + +/** Files the rescope must never rewrite. */ +function excluded(file: string): boolean { + if (file === 'scripts/rescope-vendor.ts') return true // the mapping itself + if (file.startsWith('.agents/notes/')) return true // notes record what was true when written + // Recorded model payloads quote documentation verbatim, so they must mirror the + // sources on disk — including the notes this rescope leaves alone. + if (file.startsWith('scripts/snapshots/')) return true + // The mapping documents state both names on purpose. + if (file === 'docs/rescope.md' || file === 'docs/rescope.zh.md') return true + if (file.endsWith('.i18n.yaml')) return true // blob-hash records, re-recorded by the pairing gate + if (file === 'pnpm-lock.yaml') return true // regenerated by pnpm install + if (/^vendor\/[^/]+\/(README\.md|LICENSE)$/.test(file)) return true // upstream files kept verbatim + return !EXTENSIONS.some(extension => file.endsWith(extension)) +} + +function escapeRegExp(value: string): string { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&') +} + +/** One name's rewrite, precompiled for both delimited forms. */ +interface Pattern { + readonly upstream: string + readonly from: string + readonly to: string + readonly token: RegExp + readonly yamlName: RegExp +} + +function patterns(reverse: boolean): Pattern[] { + return RENAMES + .map(rename => ({ + upstream: rename.upstream, + from: reverse ? rename.scoped : rename.upstream, + to: reverse ? rename.upstream : rename.scoped, + })) + .sort((left, right) => right.from.length - left.from.length) + .map(rename => ({ + ...rename, + token: new RegExp(`(['"\`])${escapeRegExp(rename.from)}((?:/[^'"\`\\s]*)?)\\1`, 'g'), + yamlName: new RegExp(`^(\\s*(?:-\\s*)?name:[ \\t]+)${escapeRegExp(rename.from)}([ \\t]*(?:#.*)?)$`, 'gm'), + })) +} + +function skipped(file: string, pattern: Pattern): boolean { + return GENERIC_SKIPS.some(skip => skip.file === file && skip.upstream.includes(pattern.upstream)) +} + +function rewriteLine(line: string, file: string, all: readonly Pattern[]): string { + let out = line + for (const pattern of all) { + if (skipped(file, pattern)) continue + out = out.replace(pattern.token, (_match, quote: string, subpath: string) => `${quote}${pattern.to}${subpath}${quote}`) + out = out.replace(pattern.yamlName, (_match, prefix: string, suffix: string) => `${prefix}${pattern.to}${suffix}`) + } + return out +} + +/** + * Rewrite a file's eligible lines. + * + * Markdown splits in two. Every fence is code a reader copies or a + * configuration they mount, so every fence follows the rename regardless of its + * info string. Prose follows it only under `docs/`, where a sentence quoting + * `` `cordis` `` teaches a name this repository no longer resolves; elsewhere + * prose is a record of what was true when it was written, and the same spelling + * can mean something else entirely — the Python SDK's `cordis` option, or the + * unvendored `@cordisjs/plugin-http`. + */ +function rewrite(text: string, file: string, all: readonly Pattern[]): { text: string; lines: number } { + const markdown = file.endsWith('.md') + const prose = markdown && file.startsWith('docs/') + let insideFence = false + let lines = 0 + const out = text.split('\n').map((line) => { + if (markdown) { + if (/^\s*```/.test(line)) { + insideFence = !insideFence + return line + } + if (!insideFence && !prose) return line + } + const next = rewriteLine(line, file, all) + if (next !== line) lines += 1 + return next + }) + return { text: out.join('\n'), lines } +} + +function classify(file: string): string { + if (/^vendor\/[^/]+\/package\.json$/.test(file)) return 'vendor manifest name' + if (file.endsWith('package.json')) return 'package.json dependencies' + if (/\.(ts|tsx|js|mjs|cjs|tpl)$/.test(file)) return 'code specifiers' + if (/\.(yml|yaml)$/.test(file)) return 'YAML plugin names' + if (file.endsWith('.json')) return 'JSON configuration' + return 'Markdown fences and docs prose' +} + +/** + * One exact edit's state in the text it targets. `pending` means the source + * form is present and the target form absent; `applied` means the reverse; + * anything else — a partial application, a moved site, or a DUPLICATED + * insertion — is `invalid`, so it fails the run instead of being applied again. + */ +export type ExactEditState = 'pending' | 'applied' | 'invalid' + +/** + * Classify one exact edit against its target text. + * + * An insertion keeps its anchor (`replace` contains `find`) and a deletion + * keeps its remainder (`find` contains `replace`), so neither can be judged by + * the source form alone: the surviving side counts the target form instead. + * @param text - the complete current text of the edited file. + * @param find - the source form, already oriented for the running direction. + * @param replace - the target form, already oriented for the running direction. + * @param expect - how many occurrences one complete application produces. + * @returns Whether the edit is pending, already applied, or invalid. + */ +export function exactEditState(text: string, find: string, replace: string, expect: number): ExactEditState { + const hits = text.split(find).length - 1 + const landed = text.split(replace).length - 1 + if (replace.includes(find)) { + if (landed === expect) return 'applied' + return landed === 0 && hits === expect ? 'pending' : 'invalid' + } + if (find.includes(replace)) { + if (hits === 0) return landed === expect ? 'applied' : 'invalid' + return hits === expect ? 'pending' : 'invalid' + } + if (hits === 0 && landed === expect) return 'applied' + return hits === expect && landed === 0 ? 'pending' : 'invalid' +} + +function main(): void { + const args = process.argv.slice(2) + const mode = args.includes('--apply') ? 'apply' : args.includes('--check') ? 'check' : 'dry' + const reverse = args.includes('--reverse') + const all = patterns(reverse) + const files = execFileSync('git', ['ls-files', '-z'], { cwd: root, encoding: 'utf8' }) + .split('\0') + .filter(file => file !== '' && !excluded(file)) + + const counts = new Map() + const failures: string[] = [] + const outstanding: string[] = [] + + // Classify every exact edit before writing anything: a single invalid site + // means the mapping and the tree disagree, and a half-applied tree is worse + // than an untouched one. + const planned: { edit: ExactEdit; path: string; find: string; replace: string }[] = [] + for (const edit of EXACT_EDITS) { + const path = resolve(root, edit.file) + const before = readFileSync(path, 'utf8') + const find = reverse ? edit.replace : edit.find + const replace = reverse ? edit.find : edit.replace + const state = exactEditState(before, find, replace, edit.expect) + if (state === 'invalid') { + failures.push(`exact edit ${edit.id}: ${edit.file} is neither pending nor cleanly applied (duplicated, partial, or moved)`) + continue + } + if (mode === 'check') { + if (state !== 'applied') failures.push(`exact edit ${edit.id} did not land in ${edit.file}`) + continue + } + if (state === 'pending') planned.push({ edit, path, find, replace }) + } + if (failures.length > 0) { + for (const failure of failures) console.error(`rescope-vendor: ${failure}`) + console.error(`rescope-vendor: ${String(failures.length)} problem(s); nothing was written.`) + process.exitCode = 1 + return + } + if (mode === 'apply') { + // Re-read per edit: two edits can target one file, and a stale snapshot + // would let the second write discard the first. + for (const { path, find, replace } of planned) { + writeFileSync(path, readFileSync(path, 'utf8').split(find).join(replace)) + } + } + + for (const file of files) { + const path = resolve(root, file) + const before = readFileSync(path, 'utf8') + const { text: after, lines } = rewrite(before, file, all) + if (after === before) continue + outstanding.push(file) + const kind = classify(file) + const current = counts.get(kind) ?? { files: 0, lines: 0 } + counts.set(kind, { files: current.files + 1, lines: current.lines + lines }) + if (mode === 'apply') writeFileSync(path, after) + } + + console.log(`rescope-vendor: ${mode}${reverse ? ' --reverse' : ''} over ${String(files.length)} tracked files`) + for (const kind of [...counts.keys()].sort()) { + const { files: count, lines } = counts.get(kind) ?? { files: 0, lines: 0 } + console.log(` ${kind.padEnd(24)} ${String(count).padStart(4)} file(s), ${String(lines)} line(s)`) + } + + if (mode !== 'dry') { + for (const check of POSTCONDITIONS) { + if (reverse) break + const path = resolve(root, check.file) + const hits = existsSync(path) ? readFileSync(path, 'utf8').split(check.text).length - 1 : -1 + if (hits !== check.count) { + failures.push(`postcondition: ${check.file} has ${String(hits)} occurrence(s) of ${JSON.stringify(check.text)}, expected ${String(check.count)}`) + } + } + // The generic pass above already told us which files would still change, + // which in check mode is exactly the residue-and-idempotency signal. + if (mode === 'check') { + for (const file of outstanding) failures.push(`residue: ${file} still carries a pre-rescope name token`) + } + } + + if (failures.length > 0) { + for (const failure of failures) console.error(`rescope-vendor: ${failure}`) + console.error(`rescope-vendor: ${String(failures.length)} problem(s); the mapping or an upstream site moved.`) + process.exitCode = 1 + } else if (mode === 'check') { + console.log('rescope-vendor: post-state verified — no residue, every exact edit landed, idempotent.') + } else if (mode === 'apply') { + console.log('rescope-vendor: applied. Run `pnpm install`, `pnpm run gen-third-party-notices`, and re-record the touched bilingual pairs.') + } +} + +// Importing this module for its exported classifier must not run the codemod. +if (process.argv[1] !== undefined && realpathSync(process.argv[1]) === realpathSync(fileURLToPath(import.meta.url))) { + main() +} diff --git a/scripts/run-gates.spec.ts b/scripts/run-gates.spec.ts index 7c85d8ff48..6ef494b76b 100644 --- a/scripts/run-gates.spec.ts +++ b/scripts/run-gates.spec.ts @@ -227,7 +227,7 @@ describe('Node 24 lane ownership', () => { const subject = withPnpmEntrypoint(() => gatesForMode('ci-consumers')) expect(defaultConcurrency('ci-consumers', subject.length, 4)).toEqual({ - workers: 11, + workers: 10, source: 'ci-consumers gate count', }) expect(subject.map(item => item.id)).toEqual([ @@ -241,7 +241,6 @@ describe('Node 24 lane ownership', () => { 'doc-typecheck', 'node-next-types', 'built-bin-smoke', - 'github-repository-plugin-e2e', ]) expect(subject.find(item => item.id === 'publint')?.needs).toEqual(['build']) expect(subject.find(item => item.id === 'built-package-invariants')?.needs).toEqual(['publint']) @@ -252,7 +251,6 @@ describe('Node 24 lane ownership', () => { 'doc-typecheck', 'node-next-types', 'built-bin-smoke', - 'github-repository-plugin-e2e', ]) { expect(subject.find(item => item.id === id)?.needs).toEqual(['built-package-invariants']) } @@ -266,16 +264,6 @@ describe('Node 24 lane ownership', () => { 'packages/subagent/subagent-claude-code/tests/loader-composition.e2e.ts', ]), ) - const githubRepositoryPlugin = subject.find(item => item.id === 'github-repository-plugin-e2e') - expect(githubRepositoryPlugin).toMatchObject({ - label: 'GitHub repository Plugin dsh run', - env: { - DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1', - }, - }) - expect(githubRepositoryPlugin?.args).toEqual( - expect.arrayContaining(['apps/cli/tests/github-repository-plugin.built.e2e.ts']), - ) expect(subject.find(item => item.id === 'web-snapshot')).toMatchObject({ displayCommand: 'DSH_SNAPSHOT=replay pnpm run test:web:built', env: { DSH_SNAPSHOT: 'replay' }, diff --git a/scripts/run-gates.ts b/scripts/run-gates.ts index 3b88da217f..ad7b6842d9 100644 --- a/scripts/run-gates.ts +++ b/scripts/run-gates.ts @@ -406,7 +406,6 @@ function ciConsumerGates(): Gate[] { needs: validatedBuild, }), builtBinSmokeGate(validatedBuild), - githubRepositoryPluginE2eGate(validatedBuild), ] } @@ -518,7 +517,7 @@ function coverageGates(): Gate[] { } // Example and package snapshots boot their bins in `lib` mode (built artifacts under plain Node, -// plugins via real exports); repository-script snapshots execute their real source entry path. +// plugins via real exports); script snapshots execute their real source entry path. // Callers wait either on `build` or on a validation gate that transitively owns that build. function snapshotGate(needs: string[] = ['build']): Gate { return pnpmScript('snapshot', 'test:snapshot', { @@ -554,6 +553,7 @@ function flagEnabled(envName: string): boolean { function hygieneLeafGates(options: { artifactNeeds?: string[] } = {}): Gate[] { const artifactOptions = options.artifactNeeds === undefined ? {} : { needs: options.artifactNeeds } return [ + pnpmScript('rescope-vendor', 'rescope-vendor:check', { label: 'vendor rescope' }), pnpmScript('knip', 'knip'), pnpmScript('publint', 'publint', artifactOptions), pnpmScript('constraints', 'constraints'), @@ -639,20 +639,6 @@ function builtBinSmokeGate(needs: string[] = ['build']): Gate { }) } -function githubRepositoryPluginE2eGate(needs: string[]): Gate { - return pnpmExec('github-repository-plugin-e2e', [ - 'vitest', - 'run', - '--config', - 'vitest.e2e.config.ts', - 'apps/cli/tests/github-repository-plugin.built.e2e.ts', - ], { - label: 'GitHub repository Plugin dsh run', - needs, - env: { DSH_REQUIRE_GITHUB_REPOSITORY_PLUGIN_E2E: '1' }, - }) -} - /** * Reject a gate list whose graph cannot be executed unambiguously. * @param gates - complete aggregate to validate. diff --git a/scripts/smoke-python-runtime.py b/scripts/smoke-python-runtime.py index 415784cd7a..910b4ffc0a 100644 --- a/scripts/smoke-python-runtime.py +++ b/scripts/smoke-python-runtime.py @@ -17,7 +17,7 @@ from pathlib import Path from typing import TYPE_CHECKING, Callable if TYPE_CHECKING: - from deepseek_harness import TurnResult + from deepseek_harness import RunResult EXPECTED_TEXT = "runtime smoke ok" @@ -25,10 +25,14 @@ CODE_PROMPT = "Use run_code to compute the packaged worker smoke value." CODE_WORKER_TEXT = "code worker smoke ok" WORKFLOW_PROMPT = "Use workflow to compute the packaged worker smoke value without agents." WORKFLOW_WORKER_TEXT = "workflow worker smoke ok" -PERSISTENT_TOOLS_PROMPT = "Exercise the packaged persistent Bash and string-replacement editor." -PERSISTENT_TOOLS_TEXT = "persistent tools smoke ok" -PERSISTENT_EDITOR_PATH_PREFIX = "Editor path: " -PERSISTENT_BASH_COMMAND = ( +MINIMAL_PROMPT = "Exercise the packaged minimal agent's persistent Bash and string-replacement editor." +MINIMAL_TEXT = "minimal agent smoke ok" +MINIMAL_EDITOR_PATH_PREFIX = "Editor path: " +MINIMAL_SYSTEM_PROMPT = "You are a helpful software engineer assistant." +MINIMAL_CORDIS = ( + Path(__file__).resolve().parent.parent / "examples" / "jsonrpc-agent" / "minimal.cordis.yml" +) +MINIMAL_BASH_COMMAND = ( "counter=$(( ${counter:-0} + 1 )); export counter; " "printf 'COUNT=%s CWD=%s\\n' \"$counter\" \"$PWD\"; " "if [ \"$counter\" -eq 1 ]; then cd /tmp; fi" @@ -103,51 +107,6 @@ CUSTOM_CORDIS = """\ - id: cordis-tool name: '@deepseek-ai/dsh-tool-cordis' """ -PERSISTENT_TOOLS_CORDIS = """\ -- id: jsonrpc - name: '@deepseek-ai/dsh-jsonrpc' -- id: llm - name: '@deepseek-ai/dsh-llm-deepseek' - config: - apiKey: !!js process.env.DEEPSEEK_API_KEY - baseURL: !!js process.env.DEEPSEEK_BASE_URL -- id: sandbox - name: '@deepseek-ai/dsh-sandbox-local' -- id: sandbox-policy - name: '@deepseek-ai/dsh-sandbox-policy' - config: - mode: danger-full-access - workspaceRoot: !!js process.env.DSH_CWD -- id: pty - name: '@deepseek-ai/dsh-pty' -- id: pty-local - name: '@deepseek-ai/dsh-pty-local' -- id: fs - name: '@deepseek-ai/dsh-fs-local' - config: - cwd: !!js process.env.DSH_CWD -- id: agent-core - name: '@deepseek-ai/dsh-agent-spine-demo' - config: - includeHarnessIdentity: false - persona: 'You are a helpful software engineer assistant.' - workspaceContext: false - skills: - enabled: false - toolBash: false - toolTasks: false -- id: sessions - name: '@deepseek-ai/dsh-session-persistence-jsonl' - config: - root: !!js process.env.DSH_SESSION_ROOT - compression: 'none' -- id: persistent-bash - name: '@deepseek-ai/dsh-tool-bash-persistent' -- id: str-replace-editor - name: '@deepseek-ai/dsh-tool-str-replace-editor' -""" - - class MockModelHandler(BaseHTTPRequestHandler): """Return deterministic text, worker, and orchestration completions.""" @@ -182,9 +141,9 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: if latest.get("role") == "tool": call_id, tool_name = latest_tool_call(messages) tool_text = message_text(latest.get("content")) - persistent = persistent_tool_followup(body, call_id, tool_name, tool_text) - if persistent is not None: - return persistent + minimal = minimal_tool_followup(body, call_id, tool_name, tool_text) + if minimal is not None: + return minimal advanced = advanced_tool_followup(body, call_id, tool_name, tool_text) if advanced is not None: return advanced @@ -196,16 +155,35 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(WORKFLOW_WORKER_TEXT) raise AssertionError(f"unexpected tool follow-up: {tool_name}") - prompt = message_text(latest.get("content")) - if prompt.startswith(f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}"): + minimal_prompt = next( + ( + message_text(message.get("content")) + for message in reversed(messages) + if isinstance(message, dict) + and message.get("role") == "user" + and message_text(message.get("content")).startswith( + f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}" + ) + ), + None, + ) + if minimal_prompt is not None: names = advertised_tool_names(body) if names != {"bash", "str_replace_editor"}: - raise AssertionError(f"persistent tools smoke advertised unexpected tools: {names}") + raise AssertionError(f"minimal agent smoke advertised unexpected tools: {names}") + system_prompts = [ + message_text(message.get("content")) + for message in messages + if isinstance(message, dict) and message.get("role") == "system" + ] + if system_prompts != [MINIMAL_SYSTEM_PROMPT]: + raise AssertionError(f"minimal agent smoke assembled unexpected system prompts: {system_prompts}") return tool_call_chunks( - "persistent-bash-1", + "minimal-bash-1", "bash", - {"command": PERSISTENT_BASH_COMMAND}, + {"command": MINIMAL_BASH_COMMAND}, ) + prompt = message_text(latest.get("content")) if prompt == SNAPSHOT_DIRECT_CHILD_PROMPT: return text_chunks("DIRECT_CHILD_OK") if prompt == SNAPSHOT_WORKFLOW_CHILD_PROMPT: @@ -240,24 +218,24 @@ def completion_chunks(body: dict[str, object]) -> list[dict[str, object]]: return text_chunks(EXPECTED_TEXT) -def persistent_tool_followup( +def minimal_tool_followup( body: dict[str, object], call_id: str, tool_name: str, tool_text: str, ) -> list[dict[str, object]] | None: - """Verify packaged PTY persistence, then invoke the packaged editor.""" - if not call_id.startswith("persistent-"): + """Verify the checked-in minimal composition's PTY and editor.""" + if not call_id.startswith("minimal-"): return None - if call_id == "persistent-bash-1" and tool_name == "bash": + if call_id == "minimal-bash-1" and tool_name == "bash": if "COUNT=1" not in tool_text: raise AssertionError(f"first persistent bash call lost its output: {tool_text}") return tool_call_chunks( - "persistent-bash-2", + "minimal-bash-2", "bash", - {"command": PERSISTENT_BASH_COMMAND}, + {"command": MINIMAL_BASH_COMMAND}, ) - if call_id == "persistent-bash-2" and tool_name == "bash": + if call_id == "minimal-bash-2" and tool_name == "bash": if "COUNT=2 CWD=/tmp" not in tool_text: raise AssertionError(f"persistent bash did not retain state: {tool_text}") messages = body.get("messages") @@ -265,18 +243,18 @@ def persistent_tool_followup( raise AssertionError("persistent editor smoke request has no messages") editor_path = next( ( - text.split(PERSISTENT_EDITOR_PATH_PREFIX, 1)[1].strip() + text.split(MINIMAL_EDITOR_PATH_PREFIX, 1)[1].strip() for message in messages if isinstance(message, dict) and message.get("role") == "user" for text in [message_text(message.get("content"))] - if PERSISTENT_EDITOR_PATH_PREFIX in text + if MINIMAL_EDITOR_PATH_PREFIX in text ), None, ) if editor_path is None: raise AssertionError("persistent editor smoke prompt has no editor path") return tool_call_chunks( - "persistent-editor", + "minimal-editor", "str_replace_editor", { "command": "create", @@ -284,11 +262,11 @@ def persistent_tool_followup( "file_text": "created by packaged editor\n", }, ) - if call_id == "persistent-editor" and tool_name == "str_replace_editor": + if call_id == "minimal-editor" and tool_name == "str_replace_editor": if "New file created successfully" not in tool_text: raise AssertionError(f"packaged editor did not create its file: {tool_text}") - return text_chunks(PERSISTENT_TOOLS_TEXT) - raise AssertionError(f"unexpected persistent-tools follow-up: {call_id} {tool_name}: {tool_text}") + return text_chunks(MINIMAL_TEXT) + raise AssertionError(f"unexpected minimal-agent follow-up: {call_id} {tool_name}: {tool_text}") def advanced_tool_followup( @@ -470,14 +448,14 @@ def main() -> None: parser = argparse.ArgumentParser(description=__doc__) parser.add_argument( "--scenario", - choices=("all", "sdk-default", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"), + choices=("all", "sdk-default", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"), default="all", ) parser.add_argument("--exe", type=Path) parser.add_argument("--update-snapshots", action="store_true") args = parser.parse_args() - if args.scenario in {"all", "sdk-custom", "sdk-persistent", "sdk-snapshot", "direct"} and args.exe is None: - parser.error("--exe is required for custom, persistent, snapshot, and direct scenarios") + if args.scenario in {"all", "sdk-custom", "sdk-minimal", "sdk-snapshot", "direct"} and args.exe is None: + parser.error("--exe is required for custom, minimal, snapshot, and direct scenarios") if args.update_snapshots and args.scenario not in {"all", "sdk-snapshot"}: parser.error("--update-snapshots requires --scenario sdk-snapshot or all") if args.exe is not None and not args.exe.is_file(): @@ -489,9 +467,9 @@ def main() -> None: if args.scenario in {"all", "sdk-custom"}: assert args.exe is not None smoke_sdk_custom(model.url, args.exe.resolve()) - if args.scenario in {"all", "sdk-persistent"}: + if args.scenario in {"all", "sdk-minimal"}: assert args.exe is not None - smoke_sdk_persistent_tools(model.url, args.exe.resolve()) + smoke_sdk_minimal(model.url, args.exe.resolve()) if args.scenario in {"all", "sdk-snapshot"}: assert args.exe is not None smoke_sdk_snapshot(model.url, args.exe.resolve(), args.update_snapshots) @@ -519,7 +497,6 @@ def smoke_sdk_default(base_url: str) -> None: request_timeout_seconds=60, ) as harness: result = harness.run("reply with the smoke text", session_id="default-smoke") - assert result.status == "ok", result assert result.final_response == EXPECTED_TEXT, result.final_response assert_zstd_session_log(sessions) @@ -546,46 +523,40 @@ def smoke_sdk_custom(base_url: str, executable: Path) -> None: text_result = harness.run("reply with the smoke text", session_id="custom-smoke") code_result = harness.run(CODE_PROMPT, session_id="custom-smoke") workflow_result = harness.run(WORKFLOW_PROMPT, session_id="custom-smoke") - assert text_result.status == "ok", text_result assert text_result.final_response == EXPECTED_TEXT, text_result.final_response - assert code_result.status == "ok", code_result assert code_result.final_response == CODE_WORKER_TEXT, code_result.final_response - assert workflow_result.status == "ok", workflow_result assert workflow_result.final_response == WORKFLOW_WORKER_TEXT, workflow_result.final_response assert_session_log(sessions, root, EXPECTED_TEXT, CODE_WORKER_TEXT, WORKFLOW_WORKER_TEXT) -def smoke_sdk_persistent_tools(base_url: str, executable: Path) -> None: - """Exercise native PTY state and the editor through the packaged executable.""" +def smoke_sdk_minimal(base_url: str, executable: Path) -> None: + """Exercise the checked-in minimal composition through the packaged executable.""" from deepseek_harness import DeepSeekHarness - with tempfile.TemporaryDirectory(prefix="dsh-sdk-persistent-tools-") as temporary: + with tempfile.TemporaryDirectory(prefix="dsh-sdk-minimal-") as temporary: root = Path(temporary).resolve() editor_path = root / "created.txt" - prompt = f"{PERSISTENT_TOOLS_PROMPT}\n{PERSISTENT_EDITOR_PATH_PREFIX}{editor_path}" + prompt = f"{MINIMAL_PROMPT}\n{MINIMAL_EDITOR_PATH_PREFIX}{editor_path}" sessions = root / "sessions" - cordis = root / "cordis.yml" - cordis.write_text(PERSISTENT_TOOLS_CORDIS) with DeepSeekHarness( - provider="deepseek", + provider="deepseek-official", model="smoke-model", cwd=str(root), session_root=str(sessions), - cordis=str(cordis), + cordis=str(MINIMAL_CORDIS), runtime_bin=str(executable), api_key="sk-keyless-smoke", base_url=base_url, request_timeout_seconds=60, ) as harness: - result = harness.run(prompt, session_id="persistent-tools-smoke") + result = harness.run(prompt, session_id="minimal-agent-smoke") - assert result.status == "ok", result event_text = json.dumps(result.events) - if PERSISTENT_TOOLS_TEXT not in event_text: - raise AssertionError(f"packaged tools run emitted no final response: {result.events}") + if MINIMAL_TEXT not in event_text: + raise AssertionError(f"minimal agent run emitted no final response: {result.events}") if editor_path.read_text() != "created by packaged editor\n": raise AssertionError(f"packaged editor wrote unexpected content: {editor_path.read_text()!r}") - assert_session_log(sessions, root, PERSISTENT_TOOLS_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") + assert_session_log(sessions, root, MINIMAL_TEXT, "COUNT=1", "COUNT=2 CWD=/tmp") def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) -> None: @@ -610,7 +581,6 @@ def smoke_sdk_snapshot(base_url: str, executable: Path, update_snapshots: bool) ) as harness: result = harness.run(SNAPSHOT_PROMPT, session_id=SNAPSHOT_SESSION_ID) - assert result.status == "ok", result assert result.final_response == SNAPSHOT_FINAL_TEXT, result.final_response methods = [notification.method for notification in result.notifications] if methods.count("subagent.started") != 2 or methods.count("subagent.finished") != 2: @@ -657,8 +627,8 @@ def smoke_direct(base_url: str, executable: Path) -> None: "params": {"sessionId": "direct-smoke", "contentBlocks": [{"type": "text", "text": "reply with the smoke text"}]}, }) messages = peer.read_until(lambda message: message.get("id") == "prompt") - if not any(message.get("method") == "session.finished" and message.get("params", {}).get("status") == "ok" for message in messages): - messages.extend(peer.read_until(lambda message: message.get("method") == "session.finished")) + if not any(is_idle_notification(message) for message in messages): + messages.extend(peer.read_until(is_idle_notification)) event_text = json.dumps(messages) if EXPECTED_TEXT not in event_text: raise AssertionError(f"direct runtime emitted no final response: {messages}") @@ -669,6 +639,16 @@ def smoke_direct(base_url: str, executable: Path) -> None: assert_session_log(sessions, root, EXPECTED_TEXT) +def is_idle_notification(message: dict[str, object]) -> bool: + """Return whether a JSON-RPC notification marks a session idle.""" + params = message.get("params") + return ( + message.get("method") == "session.status" + and isinstance(params, dict) + and params.get("status") == "idle" + ) + + class RuntimePeer: def __init__(self, argv: list[str], cwd: Path, environment: dict[str, str]) -> None: self.process = subprocess.Popen( @@ -776,7 +756,7 @@ def read_session_logs(sessions: Path) -> dict[str, list[dict[str, object]]]: return logs -def snapshot_child_ids(result: "TurnResult") -> list[str]: +def snapshot_child_ids(result: "RunResult") -> list[str]: """Return the two child session ids in their SDK notification order.""" child_ids: list[str] = [] for notification in result.notifications: @@ -794,7 +774,7 @@ def snapshot_child_ids(result: "TurnResult") -> list[str]: def build_snapshot_files( - result: "TurnResult", + result: "RunResult", logs: dict[str, list[dict[str, object]]], child_ids: list[str], cwd: Path, @@ -809,7 +789,6 @@ def build_snapshot_files( result_value = { "session_id": result.session_id, - "status": result.status, "final_response": result.final_response, "events": result.events, "notifications": [ @@ -834,7 +813,7 @@ def build_snapshot_files( return files -def snapshot_agent_id(result: "TurnResult", child_id: str) -> str: +def snapshot_agent_id(result: "RunResult", child_id: str) -> str: """Find the successful subagent id paired with one child session.""" for notification in result.notifications: if notification.method != "subagent.finished": diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/result.json b/scripts/snapshots/python-sdk-single-exe/advanced/result.json index 79daa0570c..dff04d578e 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/result.json +++ b/scripts/snapshots/python-sdk-single-exe/advanced/result.json @@ -1,25 +1,62 @@ { "session_id": "{{parent}}", - "status": "ok", "final_response": "ADVANCED_EXECUTABLE_OK", "events": [ { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + }, + { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 + } + }, + { + "type": "agent/inbox/spliced", + "seq": 2, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + }, + { + "type": "step/start", + "seq": 3, + "time": 0, + "data": { + "turn": 1, + "step": 1 } }, { "type": "user/message", - "seq": 1, + "seq": 4, "time": 0, "data": { "content": [ @@ -38,38 +75,34 @@ }, { "type": "session/title", - "seq": 2, + "seq": 5, "time": 0, "data": { "title": "Run the advanced packaged-runtime snapsh", "messageSeqs": [ - 1 + 4 ], "source": { "kind": "fallback" } } }, - { - "type": "step/start", - "seq": 3, - "time": 0, - "data": { - "turn": 1, - "step": 1 - } - }, { "type": "request/header", - "seq": 4, + "seq": 6, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -86,9 +119,19 @@ "reason": "initial" } }, + { + "type": "request/context", + "seq": 7, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + }, { "type": "assistant/chunk", - "seq": 5, + "seq": 8, "time": 0, "data": { "turn": 1, @@ -102,7 +145,7 @@ }, { "type": "assistant/chunk", - "seq": 6, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -118,7 +161,7 @@ }, { "type": "assistant/chunk", - "seq": 7, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -137,7 +180,7 @@ }, { "type": "assistant/chunk", - "seq": 8, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -153,7 +196,7 @@ }, { "type": "assistant/chunk", - "seq": 9, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -168,7 +211,7 @@ }, { "type": "assistant/message", - "seq": 10, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -185,7 +228,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -196,17 +239,17 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, 8, - 9 + 9, + 10, + 11, + 12 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 11, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -218,7 +261,7 @@ }, { "type": "tool/result", - "seq": 12, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -246,13 +289,13 @@ } }, "sourceEventSeqs": [ - 11 + 14 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 13, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -261,7 +304,7 @@ }, { "type": "step/start", - "seq": 14, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -270,15 +313,20 @@ }, { "type": "request/header", - "seq": 15, + "seq": 18, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -298,7 +346,7 @@ }, { "type": "assistant/chunk", - "seq": 16, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -312,7 +360,7 @@ }, { "type": "assistant/chunk", - "seq": 17, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -328,7 +376,7 @@ }, { "type": "assistant/chunk", - "seq": 18, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -347,7 +395,7 @@ }, { "type": "assistant/chunk", - "seq": 19, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -363,7 +411,7 @@ }, { "type": "assistant/chunk", - "seq": 20, + "seq": 23, "time": 0, "data": { "turn": 1, @@ -378,7 +426,7 @@ }, { "type": "assistant/message", - "seq": 21, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -395,7 +443,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -406,17 +454,17 @@ } }, "sourceEventSeqs": [ - 16, - 17, - 18, 19, - 20 + 20, + 21, + 22, + 23 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 22, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -428,9 +476,10 @@ }, { "type": "tool/code-dispatch-start", - "seq": 23, + "seq": 26, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -441,9 +490,10 @@ }, { "type": "tool/code-dispatch", - "seq": 24, + "seq": 27, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -461,7 +511,7 @@ }, { "type": "tool/result", - "seq": 25, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -489,13 +539,13 @@ } }, "sourceEventSeqs": [ - 22 + 25 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 26, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -504,7 +554,7 @@ }, { "type": "step/start", - "seq": 27, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -513,7 +563,7 @@ }, { "type": "assistant/chunk", - "seq": 28, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -527,7 +577,7 @@ }, { "type": "assistant/chunk", - "seq": 29, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -543,7 +593,7 @@ }, { "type": "assistant/chunk", - "seq": 30, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -562,7 +612,7 @@ }, { "type": "assistant/chunk", - "seq": 31, + "seq": 34, "time": 0, "data": { "turn": 1, @@ -578,7 +628,7 @@ }, { "type": "assistant/chunk", - "seq": 32, + "seq": 35, "time": 0, "data": { "turn": 1, @@ -593,7 +643,7 @@ }, { "type": "assistant/message", - "seq": 33, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -610,7 +660,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -621,17 +671,17 @@ } }, "sourceEventSeqs": [ - 28, - 29, - 30, 31, - 32 + 32, + 33, + 34, + 35 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 34, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -643,7 +693,7 @@ }, { "type": "tool/result", - "seq": 35, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -671,13 +721,13 @@ } }, "sourceEventSeqs": [ - 34 + 37 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 36, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -686,7 +736,7 @@ }, { "type": "step/start", - "seq": 37, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -695,7 +745,7 @@ }, { "type": "assistant/chunk", - "seq": 38, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -709,7 +759,7 @@ }, { "type": "assistant/chunk", - "seq": 39, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -725,7 +775,7 @@ }, { "type": "assistant/chunk", - "seq": 40, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -744,7 +794,7 @@ }, { "type": "assistant/chunk", - "seq": 41, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -760,7 +810,7 @@ }, { "type": "assistant/chunk", - "seq": 42, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -775,7 +825,7 @@ }, { "type": "assistant/message", - "seq": 43, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -792,7 +842,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -803,17 +853,17 @@ } }, "sourceEventSeqs": [ - 38, - 39, - 40, 41, - 42 + 42, + 43, + 44, + 45 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 44, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -825,7 +875,7 @@ }, { "type": "tool/result", - "seq": 45, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -853,13 +903,13 @@ } }, "sourceEventSeqs": [ - 44 + 47 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 46, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -868,7 +918,7 @@ }, { "type": "step/start", - "seq": 47, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -877,7 +927,7 @@ }, { "type": "assistant/chunk", - "seq": 48, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -891,7 +941,7 @@ }, { "type": "assistant/chunk", - "seq": 49, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -907,7 +957,7 @@ }, { "type": "assistant/chunk", - "seq": 50, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -926,7 +976,7 @@ }, { "type": "assistant/chunk", - "seq": 51, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -942,7 +992,7 @@ }, { "type": "assistant/chunk", - "seq": 52, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -957,7 +1007,7 @@ }, { "type": "assistant/message", - "seq": 53, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -974,7 +1024,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -985,17 +1035,17 @@ } }, "sourceEventSeqs": [ - 48, - 49, - 50, 51, - 52 + 52, + 53, + 54, + 55 ], "surfaceOp": "append" }, { "type": "tool/call", - "seq": 54, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -1007,7 +1057,7 @@ }, { "type": "tool/result", - "seq": 55, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -1035,13 +1085,13 @@ } }, "sourceEventSeqs": [ - 54 + 57 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 56, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -1050,7 +1100,7 @@ }, { "type": "step/start", - "seq": 57, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -1059,15 +1109,20 @@ }, { "type": "request/header", - "seq": 58, + "seq": 61, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -1086,7 +1141,7 @@ }, { "type": "assistant/chunk", - "seq": 59, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -1100,7 +1155,7 @@ }, { "type": "assistant/chunk", - "seq": 60, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -1114,7 +1169,7 @@ }, { "type": "assistant/chunk", - "seq": 61, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -1131,7 +1186,7 @@ }, { "type": "assistant/chunk", - "seq": 62, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -1147,7 +1202,7 @@ }, { "type": "assistant/chunk", - "seq": 63, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -1162,7 +1217,7 @@ }, { "type": "assistant/message", - "seq": 64, + "seq": 67, "time": 0, "data": { "turn": 1, @@ -1177,7 +1232,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -1188,17 +1243,17 @@ } }, "sourceEventSeqs": [ - 59, - 60, - 61, 62, - 63 + 63, + 64, + 65, + 66 ], "surfaceOp": "append" }, { "type": "step/end", - "seq": 65, + "seq": 68, "time": 0, "data": { "turn": 1, @@ -1207,7 +1262,7 @@ }, { "type": "turn/end", - "seq": 66, + "seq": 69, "time": 0, "data": { "turn": 1, @@ -1223,17 +1278,48 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{parent}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 } } } @@ -1243,42 +1329,14 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "user/message", - "seq": 1, - "time": 0, - "data": { - "content": [ - { - "type": "text", - "text": "Run the advanced packaged-runtime snapshot scenario." - } - ], - "source": { - "kind": "user" - }, - "role": "user", - "id": "{{messageId}}" - }, - "surfaceOp": "append" - } - } - }, - { - "method": "session.event", - "payload": { - "sessionId": "{{parent}}", - "event": { - "type": "session/title", + "type": "agent/inbox/spliced", "seq": 2, "time": 0, "data": { - "title": "Run the advanced packaged-runtime snapsh", - "messageSeqs": [ - 1 - ], - "source": { - "kind": "fallback" - } + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] } } } @@ -1303,16 +1361,66 @@ "payload": { "sessionId": "{{parent}}", "event": { - "type": "request/header", + "type": "user/message", "seq": 4, "time": 0, + "data": { + "content": [ + { + "type": "text", + "text": "Run the advanced packaged-runtime snapshot scenario." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" + }, + "surfaceOp": "append" + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "session/title", + "seq": 5, + "time": 0, + "data": { + "title": "Run the advanced packaged-runtime snapsh", + "messageSeqs": [ + 4 + ], + "source": { + "kind": "fallback" + } + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/header", + "seq": 6, + "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -1331,13 +1439,29 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{parent}}", + "event": { + "type": "request/context", + "seq": 7, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 8, "time": 0, "data": { "turn": 1, @@ -1357,7 +1481,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 6, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -1379,7 +1503,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 7, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -1404,7 +1528,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 8, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -1426,7 +1550,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -1447,7 +1571,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 10, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -1464,7 +1588,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -1475,11 +1599,11 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, 8, - 9 + 9, + 10, + 11, + 12 ], "surfaceOp": "append" } @@ -1491,7 +1615,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 11, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -1509,7 +1633,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 12, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -1537,7 +1661,7 @@ } }, "sourceEventSeqs": [ - 11 + 14 ], "surfaceOp": "append" } @@ -1549,7 +1673,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 13, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -1564,7 +1688,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 14, + "seq": 17, "time": 0, "data": { "turn": 1, @@ -1579,15 +1703,20 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 15, + "seq": 18, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -1613,7 +1742,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 16, + "seq": 19, "time": 0, "data": { "turn": 1, @@ -1633,7 +1762,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 17, + "seq": 20, "time": 0, "data": { "turn": 1, @@ -1655,7 +1784,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 18, + "seq": 21, "time": 0, "data": { "turn": 1, @@ -1680,7 +1809,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 19, + "seq": 22, "time": 0, "data": { "turn": 1, @@ -1702,7 +1831,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 20, + "seq": 23, "time": 0, "data": { "turn": 1, @@ -1723,7 +1852,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 21, + "seq": 24, "time": 0, "data": { "turn": 1, @@ -1740,7 +1869,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -1751,11 +1880,11 @@ } }, "sourceEventSeqs": [ - 16, - 17, - 18, 19, - 20 + 20, + 21, + 22, + 23 ], "surfaceOp": "append" } @@ -1767,7 +1896,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 22, + "seq": 25, "time": 0, "data": { "turn": 1, @@ -1785,9 +1914,10 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch-start", - "seq": 23, + "seq": 26, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -1804,9 +1934,10 @@ "sessionId": "{{parent}}", "event": { "type": "tool/code-dispatch", - "seq": 24, + "seq": 27, "time": 0, "data": { + "rootCallId": "advanced-code", "parentCallId": "advanced-code", "subCallId": "advanced-code:code:1", "name": "snapshot_double", @@ -1830,7 +1961,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 25, + "seq": 28, "time": 0, "data": { "turn": 1, @@ -1858,7 +1989,7 @@ } }, "sourceEventSeqs": [ - 22 + 25 ], "surfaceOp": "append" } @@ -1870,7 +2001,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 26, + "seq": 29, "time": 0, "data": { "turn": 1, @@ -1885,7 +2016,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 27, + "seq": 30, "time": 0, "data": { "turn": 1, @@ -1900,7 +2031,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 28, + "seq": 31, "time": 0, "data": { "turn": 1, @@ -1920,7 +2051,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 29, + "seq": 32, "time": 0, "data": { "turn": 1, @@ -1942,7 +2073,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 30, + "seq": 33, "time": 0, "data": { "turn": 1, @@ -1967,7 +2098,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 31, + "seq": 34, "time": 0, "data": { "turn": 1, @@ -1989,7 +2120,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 32, + "seq": 35, "time": 0, "data": { "turn": 1, @@ -2010,7 +2141,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 33, + "seq": 36, "time": 0, "data": { "turn": 1, @@ -2027,7 +2158,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2038,11 +2169,11 @@ } }, "sourceEventSeqs": [ - 28, - 29, - 30, 31, - 32 + 32, + 33, + 34, + 35 ], "surfaceOp": "append" } @@ -2054,7 +2185,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 34, + "seq": 37, "time": 0, "data": { "turn": 1, @@ -2078,17 +2209,97 @@ "payload": { "sessionId": "{{child-1}}", "event": { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Reply with exactly DIRECT_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-1}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 2, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "subagent/descriptor", + "seq": 3, + "time": 0, + "data": { + "version": 2, + "mode": "one-shot", + "provider": "spawn", + "label": "Check direct child" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "step/start", + "seq": 4, + "time": 0, + "data": { + "turn": 1, + "step": 1 } } } @@ -2099,7 +2310,7 @@ "sessionId": "{{child-1}}", "event": { "type": "user/message", - "seq": 1, + "seq": 5, "time": 0, "data": { "content": [ @@ -2124,12 +2335,12 @@ "sessionId": "{{child-1}}", "event": { "type": "session/title", - "seq": 2, + "seq": 6, "time": 0, "data": { "title": "Reply with exactly DIRECT_CHILD_OK and", "messageSeqs": [ - 1 + 5 ], "source": { "kind": "fallback" @@ -2138,36 +2349,26 @@ } } }, - { - "method": "session.event", - "payload": { - "sessionId": "{{child-1}}", - "event": { - "type": "step/start", - "seq": 3, - "time": 0, - "data": { - "turn": 1, - "step": 1 - } - } - } - }, { "method": "session.event", "payload": { "sessionId": "{{child-1}}", "event": { "type": "request/header", - "seq": 4, + "seq": 7, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -2187,13 +2388,29 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-1}}", + "event": { + "type": "request/context", + "seq": 8, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -2213,7 +2430,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 6, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -2233,7 +2450,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 7, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -2256,7 +2473,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 8, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -2278,7 +2495,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -2299,7 +2516,7 @@ "sessionId": "{{child-1}}", "event": { "type": "assistant/message", - "seq": 10, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -2314,7 +2531,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2325,11 +2542,11 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, - 8, - 9 + 9, + 10, + 11, + 12, + 13 ], "surfaceOp": "append" } @@ -2341,7 +2558,7 @@ "sessionId": "{{child-1}}", "event": { "type": "step/end", - "seq": 11, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -2356,7 +2573,7 @@ "sessionId": "{{child-1}}", "event": { "type": "turn/end", - "seq": 12, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -2367,6 +2584,13 @@ } } }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-1}}", + "status": "idle" + } + }, { "method": "subagent.finished", "payload": { @@ -2390,7 +2614,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 35, + "seq": 38, "time": 0, "data": { "turn": 1, @@ -2418,7 +2642,7 @@ } }, "sourceEventSeqs": [ - 34 + 37 ], "surfaceOp": "append" } @@ -2430,7 +2654,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 36, + "seq": 39, "time": 0, "data": { "turn": 1, @@ -2445,7 +2669,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 37, + "seq": 40, "time": 0, "data": { "turn": 1, @@ -2460,7 +2684,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 38, + "seq": 41, "time": 0, "data": { "turn": 1, @@ -2480,7 +2704,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 39, + "seq": 42, "time": 0, "data": { "turn": 1, @@ -2502,7 +2726,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 40, + "seq": 43, "time": 0, "data": { "turn": 1, @@ -2527,7 +2751,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 41, + "seq": 44, "time": 0, "data": { "turn": 1, @@ -2549,7 +2773,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 42, + "seq": 45, "time": 0, "data": { "turn": 1, @@ -2570,7 +2794,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 43, + "seq": 46, "time": 0, "data": { "turn": 1, @@ -2587,7 +2811,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2598,11 +2822,11 @@ } }, "sourceEventSeqs": [ - 38, - 39, - 40, 41, - 42 + 42, + 43, + 44, + 45 ], "surfaceOp": "append" } @@ -2614,7 +2838,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 44, + "seq": 47, "time": 0, "data": { "turn": 1, @@ -2638,17 +2862,96 @@ "payload": { "sessionId": "{{child-2}}", "event": { - "type": "turn/start", + "type": "agent/inbox/spliced", "seq": 0, "time": 0, "data": { - "turn": 1, - "trigger": { - "kind": "message", - "source": { - "kind": "user" + "target": "next-turn", + "start": 0, + "inserted": [ + { + "content": [ + { + "type": "text", + "text": "Reply with exactly WORKFLOW_CHILD_OK and nothing else." + } + ], + "source": { + "kind": "user" + }, + "role": "user", + "id": "{{messageId}}" } - } + ] + } + } + } + }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-2}}", + "status": "running" + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "turn/start", + "seq": 1, + "time": 0, + "data": { + "turn": 1 + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "agent/inbox/spliced", + "seq": 2, + "time": 0, + "data": { + "target": "next-turn", + "start": 0, + "removedCount": 1, + "inserted": [] + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "subagent/descriptor", + "seq": 3, + "time": 0, + "data": { + "version": 2, + "mode": "one-shot", + "provider": "spawn" + } + } + } + }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "step/start", + "seq": 4, + "time": 0, + "data": { + "turn": 1, + "step": 1 } } } @@ -2659,7 +2962,7 @@ "sessionId": "{{child-2}}", "event": { "type": "user/message", - "seq": 1, + "seq": 5, "time": 0, "data": { "content": [ @@ -2684,12 +2987,12 @@ "sessionId": "{{child-2}}", "event": { "type": "session/title", - "seq": 2, + "seq": 6, "time": 0, "data": { "title": "Reply with exactly WORKFLOW_CHILD_OK and", "messageSeqs": [ - 1 + 5 ], "source": { "kind": "fallback" @@ -2698,36 +3001,26 @@ } } }, - { - "method": "session.event", - "payload": { - "sessionId": "{{child-2}}", - "event": { - "type": "step/start", - "seq": 3, - "time": 0, - "data": { - "turn": 1, - "step": 1 - } - } - } - }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "request/header", - "seq": 4, + "seq": 7, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -2747,13 +3040,29 @@ } } }, + { + "method": "session.event", + "payload": { + "sessionId": "{{child-2}}", + "event": { + "type": "request/context", + "seq": 8, + "time": 0, + "data": { + "provider": "deepseek-official", + "model": "smoke-model", + "contextWindow": 1000000 + } + } + } + }, { "method": "session.event", "payload": { "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 5, + "seq": 9, "time": 0, "data": { "turn": 1, @@ -2773,7 +3082,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 6, + "seq": 10, "time": 0, "data": { "turn": 1, @@ -2793,7 +3102,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 7, + "seq": 11, "time": 0, "data": { "turn": 1, @@ -2816,7 +3125,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 8, + "seq": 12, "time": 0, "data": { "turn": 1, @@ -2838,7 +3147,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/chunk", - "seq": 9, + "seq": 13, "time": 0, "data": { "turn": 1, @@ -2859,7 +3168,7 @@ "sessionId": "{{child-2}}", "event": { "type": "assistant/message", - "seq": 10, + "seq": 14, "time": 0, "data": { "turn": 1, @@ -2874,7 +3183,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -2885,11 +3194,11 @@ } }, "sourceEventSeqs": [ - 5, - 6, - 7, - 8, - 9 + 9, + 10, + 11, + 12, + 13 ], "surfaceOp": "append" } @@ -2901,7 +3210,7 @@ "sessionId": "{{child-2}}", "event": { "type": "step/end", - "seq": 11, + "seq": 15, "time": 0, "data": { "turn": 1, @@ -2916,7 +3225,7 @@ "sessionId": "{{child-2}}", "event": { "type": "turn/end", - "seq": 12, + "seq": 16, "time": 0, "data": { "turn": 1, @@ -2927,6 +3236,13 @@ } } }, + { + "method": "session.status", + "payload": { + "sessionId": "{{child-2}}", + "status": "idle" + } + }, { "method": "subagent.finished", "payload": { @@ -2950,7 +3266,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 45, + "seq": 48, "time": 0, "data": { "turn": 1, @@ -2978,7 +3294,7 @@ } }, "sourceEventSeqs": [ - 44 + 47 ], "surfaceOp": "append" } @@ -2990,7 +3306,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 46, + "seq": 49, "time": 0, "data": { "turn": 1, @@ -3005,7 +3321,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 47, + "seq": 50, "time": 0, "data": { "turn": 1, @@ -3020,7 +3336,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 48, + "seq": 51, "time": 0, "data": { "turn": 1, @@ -3040,7 +3356,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 49, + "seq": 52, "time": 0, "data": { "turn": 1, @@ -3062,7 +3378,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 50, + "seq": 53, "time": 0, "data": { "turn": 1, @@ -3087,7 +3403,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 51, + "seq": 54, "time": 0, "data": { "turn": 1, @@ -3109,7 +3425,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 52, + "seq": 55, "time": 0, "data": { "turn": 1, @@ -3130,7 +3446,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 53, + "seq": 56, "time": 0, "data": { "turn": 1, @@ -3147,7 +3463,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -3158,11 +3474,11 @@ } }, "sourceEventSeqs": [ - 48, - 49, - 50, 51, - 52 + 52, + 53, + 54, + 55 ], "surfaceOp": "append" } @@ -3174,7 +3490,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/call", - "seq": 54, + "seq": 57, "time": 0, "data": { "turn": 1, @@ -3192,7 +3508,7 @@ "sessionId": "{{parent}}", "event": { "type": "tool/result", - "seq": 55, + "seq": 58, "time": 0, "data": { "turn": 1, @@ -3220,7 +3536,7 @@ } }, "sourceEventSeqs": [ - 54 + 57 ], "surfaceOp": "append" } @@ -3232,7 +3548,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 56, + "seq": 59, "time": 0, "data": { "turn": 1, @@ -3247,7 +3563,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/start", - "seq": 57, + "seq": 60, "time": 0, "data": { "turn": 1, @@ -3262,15 +3578,20 @@ "sessionId": "{{parent}}", "event": { "type": "request/header", - "seq": 58, + "seq": 61, "time": 0, "data": { "header": { "config": { - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model", + "maxTokens": 256000, "reasoningEffort": "high" }, + "adapterDefaults": { + "reasoningEffort": true, + "maxTokens": true + }, "system": "{{system}}", "tools": [ "cordis_inspect", @@ -3295,7 +3616,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 59, + "seq": 62, "time": 0, "data": { "turn": 1, @@ -3315,7 +3636,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 60, + "seq": 63, "time": 0, "data": { "turn": 1, @@ -3335,7 +3656,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 61, + "seq": 64, "time": 0, "data": { "turn": 1, @@ -3358,7 +3679,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 62, + "seq": 65, "time": 0, "data": { "turn": 1, @@ -3380,7 +3701,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/chunk", - "seq": 63, + "seq": 66, "time": 0, "data": { "turn": 1, @@ -3401,7 +3722,7 @@ "sessionId": "{{parent}}", "event": { "type": "assistant/message", - "seq": 64, + "seq": 67, "time": 0, "data": { "turn": 1, @@ -3416,7 +3737,7 @@ ], "source": { "kind": "model", - "provider": "deepseek", + "provider": "deepseek-official", "model": "smoke-model" }, "id": "{{messageId}}" @@ -3427,11 +3748,11 @@ } }, "sourceEventSeqs": [ - 59, - 60, - 61, 62, - 63 + 63, + 64, + 65, + 66 ], "surfaceOp": "append" } @@ -3443,7 +3764,7 @@ "sessionId": "{{parent}}", "event": { "type": "step/end", - "seq": 65, + "seq": 68, "time": 0, "data": { "turn": 1, @@ -3458,7 +3779,7 @@ "sessionId": "{{parent}}", "event": { "type": "turn/end", - "seq": 66, + "seq": 69, "time": 0, "data": { "turn": 1, @@ -3470,13 +3791,10 @@ } }, { - "method": "session.finished", + "method": "session.status", "payload": { "sessionId": "{{parent}}", - "status": "ok", - "reason": { - "kind": "completed" - } + "status": "idle" } } ], diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl index 2929f8664c..3cfcda4d28 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.1.jsonl @@ -1,14 +1,18 @@ -{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{child-1}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Check direct child"}} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly DIRECT_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly DIRECT_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"DIRECT_CHILD_OK"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"DIRECT_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl index a5da33d006..926acbcecc 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.2.jsonl @@ -1,14 +1,18 @@ -{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","delegationDepth":1} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[1],"source":{"kind":"fallback"}}} -{"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"step/end","seq":11,"time":0,"data":{"turn":1,"step":1}} -{"type":"turn/end","seq":12,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"session","version":0,"id":"{{child-2}}","createdAt":0,"cwd":"{{cwd}}","parentSession":"{{parent}}","origin":"subagent","delegationDepth":1} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":0,"data":{"version":2,"mode":"one-shot","provider":"spawn"}} +{"type":"step/start","seq":4,"time":0,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"time":0,"data":{"content":[{"type":"text","text":"Reply with exactly WORKFLOW_CHILD_OK and nothing else."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":6,"time":0,"data":{"title":"Reply with exactly WORKFLOW_CHILD_OK and","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":7,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":8,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"text-delta","index":0,"text":"WORKFLOW_CHILD_OK"}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"WORKFLOW_CHILD_OK"}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":13,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":14,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"WORKFLOW_CHILD_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[9,10,11,12,13],"surfaceOp":"append"} +{"type":"step/end","seq":15,"time":0,"data":{"turn":1,"step":1}} +{"type":"turn/end","seq":16,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl index 1f2f890b3c..65f31b21b6 100644 --- a/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl +++ b/scripts/snapshots/python-sdk-single-exe/advanced/session.jsonl @@ -1,68 +1,71 @@ {"type":"session","version":0,"id":"{{parent}}","createdAt":0,"cwd":"{{cwd}}","delegationDepth":0} -{"type":"turn/start","seq":0,"time":0,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user"}}}} -{"type":"user/message","seq":1,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} -{"type":"session/title","seq":2,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[1],"source":{"kind":"fallback"}}} +{"type":"agent/inbox/spliced","seq":0,"time":0,"data":{"target":"next-turn","start":0,"inserted":[{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"}]}} +{"type":"turn/start","seq":1,"time":0,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":0,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} {"type":"step/start","seq":3,"time":0,"data":{"turn":1,"step":1}} -{"type":"request/header","seq":4,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} -{"type":"assistant/chunk","seq":5,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":6,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} -{"type":"assistant/chunk","seq":7,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} -{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":10,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[5,6,7,8,9],"surfaceOp":"append"} -{"type":"tool/call","seq":11,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} -{"type":"tool/result","seq":12,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[11],"surfaceOp":"append"} -{"type":"step/end","seq":13,"time":0,"data":{"turn":1,"step":1}} -{"type":"step/start","seq":14,"time":0,"data":{"turn":1,"step":2}} -{"type":"request/header","seq":15,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} -{"type":"assistant/chunk","seq":16,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":17,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} -{"type":"assistant/chunk","seq":18,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} -{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":21,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[16,17,18,19,20],"surfaceOp":"append"} -{"type":"tool/call","seq":22,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} -{"type":"tool/code-dispatch-start","seq":23,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} -{"type":"tool/code-dispatch","seq":24,"time":0,"data":{"parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} -{"type":"tool/result","seq":25,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[22],"surfaceOp":"append"} -{"type":"step/end","seq":26,"time":0,"data":{"turn":1,"step":2}} -{"type":"step/start","seq":27,"time":0,"data":{"turn":1,"step":3}} -{"type":"assistant/chunk","seq":28,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":29,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} -{"type":"assistant/chunk","seq":30,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} -{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":33,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[28,29,30,31,32],"surfaceOp":"append"} -{"type":"tool/call","seq":34,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} -{"type":"tool/result","seq":35,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[34],"surfaceOp":"append"} -{"type":"step/end","seq":36,"time":0,"data":{"turn":1,"step":3}} -{"type":"step/start","seq":37,"time":0,"data":{"turn":1,"step":4}} -{"type":"assistant/chunk","seq":38,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":39,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} -{"type":"assistant/chunk","seq":40,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} -{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":43,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[38,39,40,41,42],"surfaceOp":"append"} -{"type":"tool/call","seq":44,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} -{"type":"tool/result","seq":45,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[44],"surfaceOp":"append"} -{"type":"step/end","seq":46,"time":0,"data":{"turn":1,"step":4}} -{"type":"step/start","seq":47,"time":0,"data":{"turn":1,"step":5}} -{"type":"assistant/chunk","seq":48,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} -{"type":"assistant/chunk","seq":49,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} -{"type":"assistant/chunk","seq":50,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} -{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} -{"type":"assistant/message","seq":53,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[48,49,50,51,52],"surfaceOp":"append"} -{"type":"tool/call","seq":54,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} -{"type":"tool/result","seq":55,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[54],"surfaceOp":"append"} -{"type":"step/end","seq":56,"time":0,"data":{"turn":1,"step":5}} -{"type":"step/start","seq":57,"time":0,"data":{"turn":1,"step":6}} -{"type":"request/header","seq":58,"time":0,"data":{"header":{"config":{"provider":"deepseek","model":"smoke-model","reasoningEffort":"high"},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} -{"type":"assistant/chunk","seq":59,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} -{"type":"assistant/chunk","seq":60,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} -{"type":"assistant/chunk","seq":61,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} -{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} -{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} -{"type":"assistant/message","seq":64,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[59,60,61,62,63],"surfaceOp":"append"} -{"type":"step/end","seq":65,"time":0,"data":{"turn":1,"step":6}} -{"type":"turn/end","seq":66,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} +{"type":"user/message","seq":4,"time":0,"data":{"content":[{"type":"text","text":"Run the advanced packaged-runtime snapshot scenario."}],"source":{"kind":"user"},"role":"user","id":"{{messageId}}"},"surfaceOp":"append"} +{"type":"session/title","seq":5,"time":0,"data":{"title":"Run the advanced packaged-runtime snapsh","messageSeqs":[4],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":6,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"initial"}} +{"type":"request/context","seq":7,"time":0,"data":{"provider":"deepseek-official","model":"smoke-model","contextWindow":1000000}} +{"type":"assistant/chunk","seq":8,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":9,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-mount","name":"cordis_mount","argumentsDelta":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}} +{"type":"assistant/chunk","seq":10,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}}}} +{"type":"assistant/chunk","seq":11,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":12,"time":0,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":13,"time":0,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[8,9,10,11,12],"surfaceOp":"append"} +{"type":"tool/call","seq":14,"time":0,"data":{"turn":1,"step":1,"callId":"advanced-mount","name":"cordis_mount","arguments":"{\"code\": \"return (ctx) => {\\n harness.registerTool(ctx, harness.defineTool({\\n name: 'snapshot_double',\\n description: 'Double a number for executable snapshot verification.',\\n parameters: { value: { type: 'number', required: true } },\\n output: {\\n schema: { type: 'number' },\\n render(_args, value) {\\n return [{ type: 'text', text: String(value) }]\\n }\\n },\\n async execute(args) {\\n return args.value * 2\\n }\\n }))\\n}\\n\"}"}} +{"type":"tool/result","seq":15,"time":0,"data":{"turn":1,"step":1,"message":{"source":{"kind":"tool","callId":"advanced-mount"},"content":[{"type":"tool-result","toolCallId":"advanced-mount","content":[{"type":"text","text":"Temporary Plugin dyn-1 is running (plugin \"\"; available until unmounted or DSH restarts)."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[14],"surfaceOp":"append"} +{"type":"step/end","seq":16,"time":0,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":17,"time":0,"data":{"turn":1,"step":2}} +{"type":"request/header","seq":18,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","snapshot_double","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} +{"type":"assistant/chunk","seq":19,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":20,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-code","name":"run_code","argumentsDelta":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}} +{"type":"assistant/chunk","seq":21,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}}}} +{"type":"assistant/chunk","seq":22,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":23,"time":0,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":24,"time":0,"data":{"turn":1,"step":2,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[19,20,21,22,23],"surfaceOp":"append"} +{"type":"tool/call","seq":25,"time":0,"data":{"turn":1,"step":2,"callId":"advanced-code","name":"run_code","arguments":"{\"code\": \"return await tools.snapshot_double({ value: 21 })\", \"description\": \"Run the temporary Plugin tool\"}"}} +{"type":"tool/code-dispatch-start","seq":26,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21}}} +{"type":"tool/code-dispatch","seq":27,"time":0,"data":{"rootCallId":"advanced-code","parentCallId":"advanced-code","subCallId":"advanced-code:code:1","name":"snapshot_double","arguments":{"value":21},"isError":false,"content":[{"type":"text","text":"42"}]}} +{"type":"tool/result","seq":28,"time":0,"data":{"turn":1,"step":2,"message":{"source":{"kind":"tool","callId":"advanced-code"},"content":[{"type":"tool-result","toolCallId":"advanced-code","content":[{"type":"text","text":"42"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[25],"surfaceOp":"append"} +{"type":"step/end","seq":29,"time":0,"data":{"turn":1,"step":2}} +{"type":"step/start","seq":30,"time":0,"data":{"turn":1,"step":3}} +{"type":"assistant/chunk","seq":31,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":32,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-direct-child","name":"subagent","argumentsDelta":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}} +{"type":"assistant/chunk","seq":33,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}}}} +{"type":"assistant/chunk","seq":34,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":35,"time":0,"data":{"turn":1,"step":3,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":36,"time":0,"data":{"turn":1,"step":3,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[31,32,33,34,35],"surfaceOp":"append"} +{"type":"tool/call","seq":37,"time":0,"data":{"turn":1,"step":3,"callId":"advanced-direct-child","name":"subagent","arguments":"{\"description\": \"Check direct child\", \"prompt\": \"Reply with exactly DIRECT_CHILD_OK and nothing else.\"}"}} +{"type":"tool/result","seq":38,"time":0,"data":{"turn":1,"step":3,"message":{"source":{"kind":"tool","callId":"advanced-direct-child"},"content":[{"type":"tool-result","toolCallId":"advanced-direct-child","content":[{"type":"text","text":"DIRECT_CHILD_OK"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[37],"surfaceOp":"append"} +{"type":"step/end","seq":39,"time":0,"data":{"turn":1,"step":3}} +{"type":"step/start","seq":40,"time":0,"data":{"turn":1,"step":4}} +{"type":"assistant/chunk","seq":41,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":42,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-workflow","name":"workflow","argumentsDelta":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}} +{"type":"assistant/chunk","seq":43,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}}}} +{"type":"assistant/chunk","seq":44,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":45,"time":0,"data":{"turn":1,"step":4,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":46,"time":0,"data":{"turn":1,"step":4,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[41,42,43,44,45],"surfaceOp":"append"} +{"type":"tool/call","seq":47,"time":0,"data":{"turn":1,"step":4,"callId":"advanced-workflow","name":"workflow","arguments":"{\"script\": \"phase('Delegate')\\nconst reply = await agent('Reply with exactly WORKFLOW_CHILD_OK and nothing else.', { label: 'workflow-child' })\\nreturn { reply }\", \"meta\": {\"name\": \"advanced-exe-snapshot\", \"description\": \"exercise one packaged workflow child\"}}"}} +{"type":"tool/result","seq":48,"time":0,"data":{"turn":1,"step":4,"message":{"source":{"kind":"tool","callId":"advanced-workflow"},"content":[{"type":"tool-result","toolCallId":"advanced-workflow","content":[{"type":"text","text":"workflow \"advanced-exe-snapshot\" completed (1 agent).\nReturn value:\n{\n \"reply\": \"WORKFLOW_CHILD_OK\"\n}"}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[47],"surfaceOp":"append"} +{"type":"step/end","seq":49,"time":0,"data":{"turn":1,"step":4}} +{"type":"step/start","seq":50,"time":0,"data":{"turn":1,"step":5}} +{"type":"assistant/chunk","seq":51,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":52,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"tool-call-delta","index":0,"id":"advanced-unmount","name":"cordis_unmount","argumentsDelta":"{\"id\": \"dyn-1\"}"}}} +{"type":"assistant/chunk","seq":53,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"block-end","index":0,"block":{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}}}} +{"type":"assistant/chunk","seq":54,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":55,"time":0,"data":{"turn":1,"step":5,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":56,"time":0,"data":{"turn":1,"step":5,"message":{"role":"assistant","content":[{"type":"tool-call","id":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[51,52,53,54,55],"surfaceOp":"append"} +{"type":"tool/call","seq":57,"time":0,"data":{"turn":1,"step":5,"callId":"advanced-unmount","name":"cordis_unmount","arguments":"{\"id\": \"dyn-1\"}"}} +{"type":"tool/result","seq":58,"time":0,"data":{"turn":1,"step":5,"message":{"source":{"kind":"tool","callId":"advanced-unmount"},"content":[{"type":"tool-result","toolCallId":"advanced-unmount","content":[{"type":"text","text":"Temporary Plugin dyn-1 was unmounted and removed."}],"isError":false}],"role":"user","id":"{{messageId}}"}},"sourceEventSeqs":[57],"surfaceOp":"append"} +{"type":"step/end","seq":59,"time":0,"data":{"turn":1,"step":5}} +{"type":"step/start","seq":60,"time":0,"data":{"turn":1,"step":6}} +{"type":"request/header","seq":61,"time":0,"data":{"header":{"config":{"provider":"deepseek-official","model":"smoke-model","maxTokens":256000,"reasoningEffort":"high"},"adapterDefaults":{"reasoningEffort":true,"maxTokens":true},"system":"{{system}}","tools":["cordis_inspect","cordis_mount","cordis_unmount","run_code","subagent","task_kill","task_list","task_output","workflow"]},"reason":"change"}} +{"type":"assistant/chunk","seq":62,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-start","index":0,"blockType":"text"}}} +{"type":"assistant/chunk","seq":63,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"text-delta","index":0,"text":"ADVANCED_EXECUTABLE_OK"}}} +{"type":"assistant/chunk","seq":64,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}}}} +{"type":"assistant/chunk","seq":65,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"usage","usage":{"inputTokens":3,"outputTokens":3}}}} +{"type":"assistant/chunk","seq":66,"time":0,"data":{"turn":1,"step":6,"chunk":{"type":"finish","reason":{"kind":"stop"}}}} +{"type":"assistant/message","seq":67,"time":0,"data":{"turn":1,"step":6,"message":{"role":"assistant","content":[{"type":"text","text":"ADVANCED_EXECUTABLE_OK"}],"source":{"kind":"model","provider":"deepseek-official","model":"smoke-model"},"id":"{{messageId}}"},"usage":{"inputTokens":3,"outputTokens":3}},"sourceEventSeqs":[62,63,64,65,66],"surfaceOp":"append"} +{"type":"step/end","seq":68,"time":0,"data":{"turn":1,"step":6}} +{"type":"turn/end","seq":69,"time":0,"data":{"turn":1,"reason":{"kind":"completed"}}} diff --git a/scripts/snapshots/translation-prompt-v4/request-response.expected.json b/scripts/snapshots/translation-prompt-v4/request-response.expected.json index 63050b2079..904d38f1b4 100644 --- a/scripts/snapshots/translation-prompt-v4/request-response.expected.json +++ b/scripts/snapshots/translation-prompt-v4/request-response.expected.json @@ -8,19 +8,19 @@ }, { "role": "user", - "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Install\n\nClone the repository, then run the installer:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\nThe installer requires `git` and Node `^22.19 || >=24`, offers to install `pnpm` when it is missing, prompts for a DeepSeek API key, builds the required repository artifacts, and launches the Web UI.\n\nThe default active checkout is `~/.dsh/source/current`, and the launcher is linked into `~/.local/bin`. Re-run the installer to update. [`scripts/install.sh`](scripts/install.sh) owns alternate locations, update mechanics, and recovery options.\n\n## Use DeepSeek Harness\n\n### Web UI\n\nFor the recommended local interface, choose Web UI when the installer finishes. To start it later, or after updating the active checkout, build the repository and run:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\nThe path above is the installer's default. If you set `DSH_SOURCE` or `DSH_CURRENT`, or reused an existing checkout, replace `~/.dsh/source/current` with that checkout path; see [`scripts/install.sh`](scripts/install.sh) for details. The Web UI is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\n`dsh` boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" + "content": "# DeepSeek Harness\n\nEnglish | [中文](README.zh.md)\n\nDeepSeek Harness (`dsh`) is an open-source coding agent built on the DeepSeek Harness SDK.\n\nIt uses an architecture where **everything is a plugin**.\n\n## Internal testing notice\n\nDeepSeek Harness is under internal testing. Features and interfaces may change.\n\nThe internal build uploads all Session Logs by default to help diagnose reported problems. Set `DSH_TELEMETRY_DISABLED=1` to disable telemetry. Send feedback through the internal WeChat group.\n\n## Run from source\n\nClone this repo, complete the [dependency and API-key setup](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key), then run:\n\n```sh\npnpm dsh web\n```\n\n## Use DeepSeek Harness\n\n### Web UI\n\nStart the recommended local interface from the repository root:\n\n```sh\npnpm dsh web\n```\n\nThe command builds the repository before starting the Web UI, which is served at `http://127.0.0.1:3080` by default.\n\n### Profiles\n\nThe source CLI boots profiles — ordered stacks of plugin-bundle patch layers under your own overrides in `$DSH_HOME/profiles/`:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nThe [CLI reference](apps/cli/README.md#profiles) describes profile layout, layer semantics, and config dump commands.\n\n### Headless\n\nRun one task, print the final answer, and exit:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### Automation and SDKs\n\nFrom a source checkout with `DEEPSEEK_API_KEY` in the environment or its root `.env`, start the ACP automation server:\n\n```sh\npnpm run demo:acp\n```\n\nThe [Python SDK](python/README.md) drives a bundled JSON-RPC runtime. The [examples](examples/README.md) cover the runnable headless, ACP, JSON-RPC, Code Mode, and self-referential compositions.\n\n## Why DeepSeek Harness\n\nBuilt-in capabilities cover file reading, editing, and search; shell and persistent PTY execution; reusable skills; task tracking, goals, plans, todos, and background tasks; subagents and workflows; sandboxing and approvals; settings and credentials; persistent, resumable, forkable, and queryable sessions; LSP and web access; context compaction; and telemetry. Each composition selects the subset appropriate to its surface. The Web UI includes Plan Mode.\n\n- **Everything is a plugin.** Models, tools, policies, storage, context management, and interfaces are composable [Cordis plugins](docs/user/develop/basic/index.md), so deployments can extend or replace behavior without forking the agent loop. See the [architecture](docs/architecture.md) for the underlying design.\n- **Runs are reconstructable.** Anything visible to the model is logged in the authoritative session stream; persistence, resume/fork/query, replay, telemetry, and UIs derive from the same events. See the [session-log architecture](docs/architecture.md#session-log).\n- **Code Mode (opt-in).** It exposes a `run_code` tool and a generated TypeScript SDK; only program output re-enters model context. See [Code Mode](packages/core/tools/README.md#code-mode).\n- **Self-referential Cordis tools are opt-in.** They let the agent inspect its live runtime and mount or unmount plugins while it runs. See the [Cordis tools](packages/self-modification/tool-cordis/README.md).\n\n## Community\n\nFollow DeepSeek Harness on Twitter for project updates.\n\n## Development\n\nStart with the [development guide](docs/development.md) and read the [architecture](docs/architecture.md) before changing packages.\n\nFor agents, follow [AGENTS.md](AGENTS.md).\n\nDeepSeek Harness is currently in internal testing.\n\n## License\n\n[BSD 3-Clause](LICENSE)\n\nThird-party dependencies and their licenses are disclosed in [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md).\n" }, { "role": "assistant", - "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 安装\n\n克隆仓库,然后运行安装器:\n\n```sh\ngit clone \ncd deepseek-harness\nscripts/install.sh\n```\n\n安装器要求系统已安装 `git` 和 Node `^22.19 || >=24`,缺少 `pnpm` 时可代为安装,并会提示输入 DeepSeek API 密钥,然后构建所需的仓库产物并启动 Web UI。\n\n默认生效的检出位于 `~/.dsh/source/current`,启动器链接到 `~/.local/bin`。再次运行安装器即可更新。其他位置、更新机制和恢复选项由 [`scripts/install.sh`](scripts/install.sh) 负责。\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n推荐在本地使用 Web UI;安装结束时,选择 Web UI 即可。以后需要启动时,或更新当前生效的检出后,请构建仓库并运行:\n\n```sh\n(cd ~/.dsh/source/current && pnpm run build)\ndsh web\n```\n\n上述路径是安装器的默认位置。如果你设置过 `DSH_SOURCE` 或 `DSH_CURRENT`,或者复用了已有检出,请把 `~/.dsh/source/current` 换成该检出路径;详情见 [`scripts/install.sh`](scripts/install.sh)。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n`dsh` 启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\ndsh --profile web # the browser UI (same as: dsh web)\ndsh plugin --profile tui add # install a plugin into a custom profile\ndsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\ndsh run \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" + "content": "# DeepSeek Harness\n\n[English](README.md) | 中文\n\nDeepSeek Harness(`dsh`)是一款基于 DeepSeek Harness SDK 构建的开源 coding agent(智能体)。\n\n它采用了**一切皆插件**的架构。\n\n## 内测声明\n\nDeepSeek Harness 正处于内部测试阶段,功能和接口可能发生变化。\n\n为帮助诊断上报的问题,内测版本默认上传所有会话日志。设置 `DSH_TELEMETRY_DISABLED=1` 可关闭遥测。请通过内部企业微信群反馈问题和建议。\n\n## 从源码运行\n\n克隆本仓库,完成[依赖安装和 API 密钥配置](docs/user/guide/quickstart.md#step-1-install-and-configure-the-api-key),然后运行:\n\n```sh\npnpm dsh web\n```\n\n## 使用 DeepSeek Harness\n\n### Web UI\n\n请从仓库根目录启动推荐的本地界面:\n\n```sh\npnpm dsh web\n```\n\n该命令会先构建仓库,再启动 Web UI。Web UI 默认通过 `http://127.0.0.1:3080` 提供服务。\n\n### Profile\n\n源码 CLI(命令行界面)会启动 profile:按序叠放的插件组合包 patch 层,之上再叠加你在 `$DSH_HOME/profiles/` 中的自有覆盖层:\n\n```sh\npnpm dsh --profile web # the browser UI\npnpm dsh plugin --profile tui add # install a plugin into a custom profile\npnpm dsh --profile tui # boot it\n```\n\nprofile 布局、层语义与配置输出命令详见 [CLI(命令行界面)参考](apps/cli/README.md#profiles)。\n\n### Headless\n\n运行一项任务,打印最终答案后退出:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n### 自动化与 SDK\n\n在源码检出中通过环境变量或根目录 `.env` 设置 `DEEPSEEK_API_KEY`,然后启动 ACP(Agent Client Protocol)自动化服务器:\n\n```sh\npnpm run demo:acp\n```\n\n[Python SDK](python/README.md) 驱动随附的 JSON-RPC 运行时。[示例](examples/README.md)涵盖可运行的 headless、ACP、JSON-RPC、Code Mode 和自指组合。\n\n## 为什么选择 DeepSeek Harness\n\n内置功能涵盖文件读取、编辑与搜索、shell 和持久 PTY 执行、可复用 skill(技能)、任务跟踪、目标、计划、待办事项与后台任务、subagent 与工作流、沙箱与审批、设置与凭据、可持久化、恢复、fork 与查询的会话、LSP 与 Web 访问、上下文压缩(context compaction),以及遥测。每个组合只选用适合其使用方式的能力子集。Web UI 包含 Plan Mode。\n\n- **一切皆插件。** 模型、工具、策略、存储、上下文管理和界面均为可组合的 [Cordis 插件](docs/user/develop/basic/index.md),部署方无需 fork agent loop(智能体循环)即可扩展或替换行为。底层设计见[架构文档](docs/architecture.md)。\n- **运行可重建。** 凡是模型可见的内容,都会记录在权威会话流中;持久化、恢复/fork/查询、回放、遥测和 UI 均从同一组事件派生。参见[会话日志架构](docs/architecture.md#session-log)。\n- **Code Mode(需显式启用)。** 它会提供 `run_code` 工具和生成的 TypeScript SDK,只有程序输出会重新进入模型上下文。参见 [Code Mode](packages/core/tools/README.md#code-mode)。\n- **自指 Cordis 工具需显式启用。** 这些工具可让 agent 检查自身的实时运行时,并在运行中挂载或卸载插件。参见 [Cordis 工具](packages/self-modification/tool-cordis/README.md)。\n\n## 社区\n\n扫描二维码,或打开 DeepSeek Harness 微信社区申请页面 申请加入。\n\n

\n \"DeepSeek\n

\n\n## 开发\n\n请先阅读[开发指南](docs/development.md);修改包之前,请阅读[架构文档](docs/architecture.md)。\n\n面向 agent:遵循 [AGENTS.md](AGENTS.md)。\n\nDeepSeek Harness 目前处于内测阶段。\n\n## 许可证\n\n[BSD 3-Clause](LICENSE)\n\n第三方依赖及其许可证在 [THIRD_PARTY_NOTICES.md](THIRD_PARTY_NOTICES.md) 中披露。\n" }, { "role": "user", - "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" + "content": "# Development guide\n\nEnglish | [中文](development.zh.md)\n\nThe setup tutorial takes a new contributor from prerequisites to a checked checkout. The contributor reference that follows covers repository layout, daily workflow, and CI organization. Design rationale and implementation details belong to the linked Agent Notes and scripts.\n\n## Setup tutorial\n\n### Prerequisites\n\n- Node.js supports 22.19+ and 24+. CI covers 22.19, 24, and 26; see the [Node engine floor Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md).\n- Corepack-enabled pnpm. The repo pins `pnpm@11.7.0` in `package.json`; run `corepack enable` if `pnpm --version` does not resolve through Corepack.\n- Git 2.26 or newer; hook setup enables Git's worktree-specific configuration extension.\n- Optional: a DeepSeek API key for the Web, headless, and ACP automation demos and real-API e2e tests.\n\n### First-time setup\n\nInstall dependencies from the repo root:\n\n```sh\npnpm install\n```\n\nThe install also configures worktree-local Lefthook hooks and the `dsh-translation-pairing` Git merge driver through `scripts/install-lefthook.mjs`. The [worktree-local hooks Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) owns the hook-path safety contract; the [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) owns the merge driver.\n\nIf either integration is missing because dependencies were restored from cache or `postinstall` was skipped, install them manually:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\nIf the wrapper rejects existing Git configuration or reports a stale lock, follow its diagnostic and the linked Agent Note rather than editing worktree metadata speculatively. After moving a checkout, rerun the wrapper to regenerate the owned path.\n\nRun typecheck once after a fresh clone:\n\n```sh\npnpm run typecheck\n```\n\nSetup is complete when `pnpm run typecheck` exits successfully.\n\n## Contributor reference\n\n### TypeScript project layout\n\nThe repository uses isolated Host and Client aggregates. An ordinary package is registered in exactly one aggregate: Host packages in `tsconfig.host.json` and Client packages in `tsconfig.client.json`.\n\n| File | Role | Forms a program? |\n|---|---|---|\n| `tsconfig.json` | Solution root: `extends` base, `files: []`, and references to the two aggregates. It is the tsserver discovery entry and the entry for explicitly running the complete Project Reference graph; through the inherited `paths`, it is also the resolution config for tsx running `examples/` and `scripts/`. | No |\n| `tsconfig.host.json` | Host aggregate: Host packages, examples, tests, scripts, website, and the exceptional Host project of `api/remotes`. | Yes |\n| `tsconfig.client.json` | Client aggregate: `packages/client/*` packages and their tests, `apps/web`, and the exceptional Client project of `api/remotes`. | Yes |\n| `tsconfig.base.json` | Shared compilerOptions and the source `paths` map. Also the resolution facade the vitest configs point vite-tsconfig-paths at: it has no `include`, so its `paths` apply to every importer. | No |\n| `tsconfig.base.client.json` | Browser compiler settings (`jsx`, DOM libs, `types: []`) extended by the Client aggregate and every `packages/client/*` package. | No |\n\nHost and Client stay two aggregate programs because both sides declaration-merge the cordis `Context` interface under the same keys with different services; one program seeing both merges reports a collision. The collision exists only inside a `ts.Program` — module resolution never triggers it — which is why the solution may reference both aggregates and one paths facade may span both sides. Three disciplines follow:\n\n- `tsconfig.base.json` never gains `include` or `files`: they would leak into every extending package project and narrow the facade's match-all scope.\n- A script that builds a repo-wide `ts.Program` seeds `tsconfig.host.json` or `tsconfig.client.json` explicitly — never the root solution, because flattening both aggregates into one program collides the `Context` merges.\n- A new package is registered in exactly one aggregate. Having both a Node loader entry and a browser entry is not a reason to split a package; an ordinary Client plugin produces both runtime artifacts during the Client build phase.\n\n`api/remotes` is the repository's only package with split Host and Client tsconfigs. Its Host entry must participate in the Host TypeRT graph, while its Client entry imports `/remote` declarations that Host tsdown must generate first. The package-root `tsconfig.json` is therefore only a solution, and the two aggregates and direct consumers reference `tsconfig.host.json` or `tsconfig.client.json` respectively. The workspace `constraints` gate walks the reachable Project Reference graph and checks each referencing project's own compiler face: a single-config target remains valid from either face, while a split target must name the matching leaf rather than its solution root or opposite leaf. Do not copy this structure to other packages; the [`api-remotes` README](../packages/api/remotes/README.md) explains the Host/Client split and build order.\n\nThe root build follows the generated dependency order:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\nBoth tsdown passes use the same complete workspace match. They neither scan build artifacts to discover Client packages nor maintain a Host/Client package filter list. Package-local tsdown configs select entries for the current phase through `DSH_BUILD_FACE`: an ordinary Client plugin produces both its Node loader and browser bundle during the Client phase; `api-remotes` uses `hostPhase: true` to produce its Host entry early and only its browser bundle during the Client phase. Tsdown consumes only the JavaScript emitted to `lib/types` by the preceding tsc phase.\n\nTypeRT runs only during Host tsdown, seeded by `tsconfig.host.json`. It analyzes Host types and generates both Host reflection artifacts and the Host-for-Client Remote projection; Client tsdown does not start TypeRT. Consequently, `pnpm run typecheck` runs the complete Host lib phase before Client tsc, while `pnpm run build` continues through Client tsdown and the Web build. The [API Remotes generated-contract build note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md) records this ordering decision.\n\nStatic analysis and tests resolve workspace imports through the base `paths` map to `src` and must pass on a clean tree; gates that consume built `lib/` output declare that dependency explicitly. Generated Host-for-Client Remote declarations are the deliberate exception: the public `typecheck`, `lint`, and `doc-typecheck` commands generate them first, while internal `*:contracts-ready` scripts assume that an invoking public command or scheduler gate already depends on the TypeRT contract-generation pass or the complete build. See the [solution-root note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md) for the two-aggregate setup, the [ts-build-config note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md) for tsc-first emit ownership, and the [TypeRT Remote note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md) for the gate-preparation contract.\n\nBusiness services declare callable methods on the Host with `@Remote` or `@RemoteScope`; the Host build generates Host-for-Client types and runtime contributions, and the Client's `api-remotes` composition loads those contributions under `ctx.remote` and scoped `agentCtx.remote` namespaces. See [API Gateway](api-gateway.md) for the generated artifacts on both sides, their assembly relationships, the SRC development fallback, and the Web build order.\n\nIf a relevant local check consumes built package output, build once first:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` includes `publint`, which validates package entrypoints against the built `lib/*.js` files, and `verify-node-next-types`, which validates built declarations against a temporary NodeNext consumer. A fresh worktree has no bundled JS or declarations until `pnpm run build` runs; ordinary commits and pushes do not require that build unless their selected checks consume it.\n\n### Environment variables\n\nThe real DeepSeek adapter and key-backed agent demos read credentials from the environment or from a gitignored `.env` at the repo root:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` is optional and defaults to the public API. Never commit real credentials. The real-API e2e suites self-skip when `DEEPSEEK_API_KEY` is not set.\n\n### Git integrations\n\nThe pairing merge driver derives a conflicted `.i18n.yaml` record from the confirmed ancestor, current, and other owner blobs when both language files use Git's default text strategy and merge cleanly. It fails closed on owner conflicts, non-text merge configuration, or invalid records; after an already-stopped merge, run `pnpm run resolve-translation-pairing-conflicts`, which stages every safe pairing record and exits unsuccessfully if other pairing conflicts still need manual work. See the [bilingual documentation contract](i18n/README.md#the-pairing-contract) for the exact files and states the driver accepts.\n\nThe installer probes the exact Node/tsx driver entrypoint before publishing its worktree configuration. If that runtime later becomes unavailable, the Node-independent launcher writes Git's ordinary text result, leaves the sidecar unresolved, and prints the recovery path; restore dependencies and run `pnpm run resolve-translation-pairing-conflicts`, or run `git merge --abort`. If `pre-merge-commit` rejects an otherwise clean merge, Git leaves the complete result staged without a commit; repair the failure and run `git commit`, or abort. The [automatic pairing merges Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract) owns the exact index and `MERGE_HEAD` states.\n\nlefthook is configured in `lefthook.yml` as a fast local checkpoint:\n\n- `pre-commit` verifies staged pairing records against the staged owner blobs, validates staged files with the project-free `.oxlintrc.staged.json` profile and applies Oxlint fixes with one bounded retry, regenerates `THIRD_PARTY_NOTICES.md` when a staged file is one of its inputs, checks the staged diff for whitespace errors, and runs the vendor manifest guard.\n- `pre-merge-commit` performs the same index-backed pairing check before Git creates an automatic merge commit.\n- `pre-push` runs `pnpm run typecheck`, which completes the Host lib phase, including generated TypeRT contracts, before the Client TypeScript check.\n\nThe vendor manifest guard checks that changes under `vendor/*/src` are staged with the matching `vendor/README.md` manifest update. See `vendor/README.md` before editing vendored code.\n\nApart from the scoped staged-record verification, the hooks intentionally do not run tests, snapshots, documentation checks, builds, or hygiene. Contributors run the [checks relevant to the changed behavior](../AGENTS.md#run-relevant-checks-locally) once; CI owns exhaustive coverage, built-artifact smokes, and the Node 22.19, 24, and 26 compatibility matrix.\n\nContributors can opt into the comprehensive local gate set with `pnpm run check:all`. The command is independent of the Git hooks and is not an agent instruction.\n\n### CI gates\n\nThe keyless [CI workflow](../.github/workflows/ci.yml) groups independent gates into broad lanes and runs a smaller compatibility signal across supported Node versions. Artifact consumers wait for one build within their lane. The separate real-API workflow runs `pnpm run test:e2e` with its configured worker bound. See [scripts/run-gates.ts](../scripts/run-gates.ts) and the workflow files for the current gate and job inventory.\n\n### Daily commands\n\nThe root [contributor instructions](../AGENTS.md#commands) summarize common commands, while [`package.json`](../package.json) and [scripts/run-gates.ts](../scripts/run-gates.ts) own the current script and gate inventories. Select the smallest checks that cover the changed surface. Documentation changes use `pnpm run doc-sync`; package-public behavior changes also update the owning README or JSDoc, and built-artifact checks require `pnpm run build` first.\n\n### Demos\n\nThe one-shot Headless coding agent needs `DEEPSEEK_API_KEY` in the environment or repo-root `.env`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\nThe self-referential cordis demo can inspect and modify its live plugin runtime and needs the same credentials (`web` by default, or `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nThe ACP automation server exposes fresh agent sessions over JSON-RPC stdio and also needs `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO markers\n\nUse one of three comment tags to flag known issues in the code, ordered by urgency:\n\n- `FIXME` — an issue that should block a new release. A release should not ship with an open `FIXME` unless reviewers explicitly agree the change can be merged anyway.\n- `TODO` — an issue that should be fixed soon, once we have the resources.\n- `XXX` — an issue that we may fix someday; lowest priority, no commitment.\n\nPick the tag that matches the urgency so anyone scanning the code can tell a release blocker from a someday-maybe.\n\n### Documenting types verbatim (`ts type-equiv`)\n\nThe [subsystems](subsystems/README.md) pages paste source-equivalent declarations together with their original JSDoc so a reader sees the exact type definition and source contract. To keep a paste from drifting when source changes, fence it as ` ```ts type-equiv ` (instead of ` ```ts `) and register it in `scripts/type-equiv.manifest.json` with the source file and symbol it mirrors:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv` (part of `doc-sync`) then extracts that symbol's declaration and attached JSDoc from source via the TypeScript parser and asserts the block matches both. For a class whose implementation bodies do not belong in the catalog, use ` ```ts public-api ` and set `\"projection\": \"public-api\"`; the checked projection retains the public fields, constructor, accessors, methods, and original class/member JSDoc while omitting bodies and private or protected members. Comparison ignores whitespace and non-JSDoc comments but requires every original JSDoc comment, including member documentation, so readers see the source contract beside the exact type definition. The gate enforces a 1:1 correspondence by document, symbol, and projection between primary blocks and manifest entries; a paired `.zh.md` block reuses its unsuffixed sibling's entry only when the whole tracked fence sequence is byte-identical and ordered identically. `doc-typecheck` applies the same derivative rule to compilable fences, while skipping both source-equivalence fence kinds from compilation and its opt-out ratio. When you change a documented declaration or its JSDoc, the gate fails until you update the paste; when you add or remove a primary block, update the manifest in the same change.\n" }, { "role": "assistant", - "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" + "content": "# 开发指南\n\n[English](development.md) | 中文\n\n搭建教程引导新贡献者从准备前置条件开始,直到检出通过检查。后面的贡献者参考介绍仓库布局、日常工作流和 CI 组织方式。设计依据与实现细节属于链接的 Agent Note 和脚本。\n\n## 搭建教程\n\n### 前置条件\n\n- Node.js 支持 22.19+ 与 24+。CI 覆盖 22.19、24 和 26;见 [Node 引擎下限 Agent Note](../.agents/notes/implemented/process/2026-07-06-node-engine-floor.md)。\n- 启用了 Corepack 的 pnpm。仓库在 `package.json` 中固定使用 `pnpm@11.7.0`;如果 `pnpm --version` 无法通过 Corepack 解析,请先运行 `corepack enable`。\n- Git 2.26 或更高版本;钩子设置会启用 Git 的 worktree 专属配置扩展。\n- 可选:一个 DeepSeek API key,用于 Web、headless 和 ACP(Agent Client Protocol)自动化 agent(智能体)演示以及真实 API 的 e2e 测试。\n\n### 首次搭建\n\n在仓库根目录安装依赖:\n\n```sh\npnpm install\n```\n\n安装过程还会通过 `scripts/install-lefthook.mjs` 配置 worktree 本地的 Lefthook 钩子和 `dsh-translation-pairing` Git 合并驱动。[worktree 本地钩子 Agent Note](../.agents/notes/implemented/process/2026-07-27-worktree-local-lefthook.md) 负责钩子路径的安全约定;[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md) 负责合并驱动。\n\n如果依赖是从缓存恢复或 `postinstall` 被跳过而导致任一集成缺失,请手动安装:\n\n```sh\nnode scripts/install-lefthook.mjs\n```\n\n如果包装脚本拒绝现有 Git 配置或报告陈旧锁,请遵循其诊断和所链接的 Agent Note,不要凭猜测编辑 worktree 元数据。移动检出目录后,请重新运行包装脚本以重新生成自有路径。\n\n新克隆后请先运行一次类型检查:\n\n```sh\npnpm run typecheck\n```\n\n`pnpm run typecheck` 成功退出即表示搭建完成。\n\n## 贡献者参考\n\n### TypeScript 项目布局\n\n仓库使用相互隔离的 Host 与 Client aggregate。普通 package 只登记进其中一个 aggregate;Host 包进入 `tsconfig.host.json`,Client 包进入 `tsconfig.client.json`。\n\n| 文件 | 角色 | 是否构成 program? |\n|---|---|---|\n| `tsconfig.json` | solution 根:`extends` base、`files: []`、引用两个 aggregate。它是 tsserver 发现入口,也是显式执行整张 Project Reference 图时的入口;经继承的 `paths` 充当 tsx 运行 `examples/` 与 `scripts/` 时的解析配置。 | 否 |\n| `tsconfig.host.json` | Host aggregate:Host package、示例、测试、脚本和 website,以及 `api/remotes` 的 Host 特例 project。 | 是 |\n| `tsconfig.client.json` | Client aggregate:`packages/client/*` package 及其测试、`apps/web`,以及 `api/remotes` 的 Client 特例 project。 | 是 |\n| `tsconfig.base.json` | 共享 compilerOptions 与源码 `paths` 映射。同时是各 vitest 配置让 vite-tsconfig-paths 指向的解析门面:它没有 `include`,因此其 `paths` 适用于任何 importer。 | 否 |\n| `tsconfig.base.client.json` | 浏览器编译设置(`jsx`、DOM lib、`types: []`),由 Client aggregate 和每个 `packages/client/*` package extends。 | 否 |\n\nHost 与 Client 保持两个 aggregate program,是因为两侧在相同键下以不同服务对 cordis `Context` 接口做声明合并;单一 program 同时看到两份合并会报冲突。这种冲突只存在于 `ts.Program` 内部——模块解析永远不会触发它——所以 solution 可以同时引用两个 aggregate,一个 paths 门面也可以横跨两侧。由此推出三条纪律:\n\n- `tsconfig.base.json` 永不添加 `include` 或 `files`:它们会泄漏进每个 extends 它的包项目,并收窄门面的全匹配范围。\n- 构造全仓 `ts.Program` 的脚本显式种子 `tsconfig.host.json` 或 `tsconfig.client.json`——永不种子根 solution,因为把两个 aggregate 展平进一个 program 会撞上 `Context` 合并冲突。\n- 新 package 只登记进一个 aggregate。包同时具有 Node loader 入口和 browser 入口并不构成拆分理由;普通 Client plugin 的两份运行时产物都在 Client 构建阶段生成。\n\n`api/remotes` 是唯一拆分 Host/Client tsconfig 的仓库特例。它的 Host 入口必须进入 Host TypeRT 图,而 Client 入口导入 Host tsdown 才会生成的 `/remote` 声明,因此本包根 `tsconfig.json` 只作为 solution,两个 aggregate 和直接消费方分别引用 `tsconfig.host.json` 或 `tsconfig.client.json`。workspace `constraints` 门禁遍历可达的 Project Reference 图,并按各引用 project 自身的 compiler face 检查:只有单一配置的目标可由任一 face 引用,拆分配置的目标则必须引用匹配的 leaf,不得引用 solution 根或另一侧 leaf。不要把该结构推广到其他包;[`api-remotes` README](../packages/api/remotes/README.md) 说明 Host/Client 拆分与构建顺序。\n\n根构建按生成依赖排序:\n\n```sh\ntsc -b tsconfig.host.json\ntsdown --env.DSH_BUILD_FACE host\ntsc -b tsconfig.client.json\ntsdown --env.DSH_BUILD_FACE client\npnpm run build:web\n```\n\n两次 tsdown 都使用同一组完整 workspace 匹配,不扫描构建产物来发现 Client package,也不维护 Host/Client package 过滤表。包内 tsdown 配置根据 `DSH_BUILD_FACE` 决定当前阶段的入口:普通 Client plugin 在 Client 阶段同时生成 Node loader 与 browser bundle;`api-remotes` 通过 `hostPhase: true` 提前生成 Host 入口,再在 Client 阶段只生成 browser bundle。tsdown 只消费 `lib/types` 中由前置 tsc 发射的 JavaScript。\n\nTypeRT 只在 Host tsdown 中以 `tsconfig.host.json` 为种子运行。它分析 Host 类型并生成 Host 反射产物及 Host-for-Client Remote 投影;Client tsdown 不启动 TypeRT。`pnpm run typecheck` 因此先执行完整 Host lib 阶段,再运行 Client tsc;`pnpm run build` 继续执行 Client tsdown 和 Web 构建。该顺序的决策记录见 [API Remotes 生成约定构建 Note](../.agents/notes/implemented/process/2026-08-08-api-remotes-generated-contract-build.md)。\n\n静态分析和测试通过 base 的 `paths` 映射把工作区 import 解析到 `src`,且必须在干净树上通过;消费构建产物 `lib/` 的门禁显式声明该依赖。生成的 Host-for-Client Remote 声明是有意设置的例外:公共 `typecheck`、`lint` 和 `doc-typecheck` 命令会先生成这些声明,而内部 `*:contracts-ready` 脚本假定调用它的公共命令或调度器门禁已经依赖 TypeRT 约定生成阶段或完整构建。两个 aggregate 的设置见 [solution-root Note](../.agents/notes/implemented/process/2026-07-22-tsconfig-solution-root-two-aggregates.md),tsc-first 发射职责见 [ts-build-config Note](../.agents/notes/implemented/process/2026-06-17-ts-build-config.md),门禁准备约定见 [TypeRT Remote Agent Note](../.agents/notes/implemented/architecture/2026-08-02-typert-remote-method-calls.md)。\n\n业务 Service 在 Host 使用 `@Remote` 或 `@RemoteScope` 声明可调用方法;Host 构建生成 Host-for-Client 类型与运行时贡献,Client 的 `api-remotes` 组合加载这些贡献并挂到 `ctx.remote` 与作用域 `agentCtx.remote` namespace。两侧的生成产物、装配关系、SRC 开发回退和 Web 构建顺序见 [API Gateway](api-gateway.md)。\n\n如果相关的本地检查需要使用构建后的包产物,请先构建一次:\n\n```sh\npnpm run build\n```\n\n`pnpm run hygiene` 包含 `publint`(用构建出的 `lib/*.js` 文件校验 package 入口点)和 `verify-node-next-types`(用一个临时的 NodeNext 消费方校验构建出的声明文件)。新 worktree 在 `pnpm run build` 运行之前没有打包的 JS 和声明文件;普通提交和推送无需构建,除非所选检查会使用这些产物。\n\n### 环境变量\n\n真实的 DeepSeek 适配器和需要密钥的 agent 演示从环境变量或仓库根目录一个被 gitignore 的 `.env` 文件读取凭证:\n\n```sh\nDEEPSEEK_API_KEY=sk-...\nDEEPSEEK_BASE_URL=https://... # optional\n```\n\n`DEEPSEEK_BASE_URL` 可选,默认为公开 API。请勿提交真实凭证。未设置 `DEEPSEEK_API_KEY` 时,真实 API 的 e2e 套件会自动跳过。\n\n### Git 集成\n\n当两种语言的文件都使用 Git 默认文本策略且能干净合并时,配对合并驱动会根据已确认的祖先、当前和另一侧的配对文档 blob,推导出发生冲突的 `.i18n.yaml` 记录。配对文档发生冲突、存在非文本合并配置或记录无效时,它会拒绝处理并保留冲突;如果合并已经因冲突而停止,请运行 `pnpm run resolve-translation-pairing-conflicts`,该命令会暂存每份可安全生成的配对记录;如果其他配对冲突仍需手工处理,则以非零状态退出。[双语文档约定](i18n/README.md#the-pairing-contract)列出该驱动接受的确切文件和状态。\n\n安装脚本在发布 worktree 配置前,会探测确切的 Node/tsx 驱动入口点。如果该运行时之后变得不可用,不依赖 Node 的启动器会写入 Git 的普通文本合并结果、让伴随文件保持未解决状态,并打印恢复路径;请恢复依赖后运行 `pnpm run resolve-translation-pairing-conflicts`,或运行 `git merge --abort`。如果 `pre-merge-commit` 拒绝原本能干净完成的合并,Git 会把完整结果留在暂存区但不创建提交;请修复失败后运行 `git commit`,或中止合并。确切的索引与 `MERGE_HEAD` 状态由[自动配对合并 Agent Note](../.agents/notes/implemented/process/2026-08-08-automatic-translation-pairing-merges.md#failure-contract)负责记录。\n\nlefthook 在 `lefthook.yml` 中配置,作为快速的本地检查点:\n\n- `pre-commit` 对照暂存的配对文档 blob 校验暂存的配对记录,使用不加载项目的 `.oxlintrc.staged.json` 配置验证暂存文件,并通过一次有界重试应用 Oxlint 修复,在暂存文件属于 `THIRD_PARTY_NOTICES.md` 的输入时重新生成该文件,然后检查暂存 diff 中的空白错误,并运行 vendor manifest(元数据清单)守卫;\n- `pre-merge-commit` 在 Git 创建自动合并提交前执行同样以索引为准的配对检查;\n- `pre-push` 运行 `pnpm run typecheck`;该命令会先完成包含 TypeRT 约定生成的完整 Host lib 阶段,再运行 Client TypeScript 检查。\n\nvendor manifest 守卫检查 `vendor/*/src` 下的改动是否连同对应的 `vendor/README.md` manifest 更新一起暂存。请在编辑 vendor 代码前先阅读 `vendor/README.md`。\n\n除限定范围的暂存记录校验外,这些钩子有意不运行测试、快照、文档检查、构建或 `hygiene`。贡献者只运行一次[与改动行为相关的检查](../AGENTS.md#run-relevant-checks-locally);CI 负责全量覆盖率门禁、构建产物冒烟测试,以及 Node 22.19、24 和 26 兼容性矩阵。\n\n贡献者可以选择运行 `pnpm run check:all`,执行全面的本地门禁集。该命令独立于 Git 钩子,也不是对 agent 的指令。\n\n### CI 门禁\n\nkeyless [CI 工作流](../.github/workflows/ci.yml) 将独立门禁分组到若干宽粒度 lane,并在受支持的 Node 版本上运行一组较小的兼容性检查。产物消费方在各自 lane 内等待一次 build。单独的真实 API 工作流按其配置的 worker 上限运行 `pnpm run test:e2e`。当前门禁和 job 清单以 [scripts/run-gates.ts](../scripts/run-gates.ts) 和工作流文件为准。\n\n### 日常命令\n\n根目录的[贡献者说明](../AGENTS.md#commands)概述常用命令,[`package.json`](../package.json) 与 [scripts/run-gates.ts](../scripts/run-gates.ts) 则负责当前脚本和门禁清单。请选择覆盖变更表面的最小检查集。文档变更使用 `pnpm run doc-sync`;package 公开行为变更还需更新所属 README 或 JSDoc,而基于构建产物的检查需要先运行 `pnpm run build`。\n\n### 演示\n\n单次运行的 Headless coding agent 需要环境变量或仓库根目录 `.env` 中的 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm dsh --profile headless \"summarize this workspace\"\n```\n\n自指的 cordis 演示可以检查并修改其实时插件运行时,并需要相同的凭证(默认 `web`,也可用 `acp`):\n\n```sh\npnpm run demo:cordis\n```\n\nACP 自动化服务器通过 JSON-RPC stdio 提供全新 agent 会话,同样需要 `DEEPSEEK_API_KEY`:\n\n```sh\npnpm run demo:acp\n```\n\n### TODO 标记\n\n请使用以下三种注释标签之一标记代码中的已知问题,按紧急程度排序:\n\n- `FIXME`:应当阻塞新版本发布的问题。除非评审者明确同意该更改可以合并,否则发布版本不应包含未解决的 `FIXME`;\n- `TODO`:应当尽快修复的问题,等资源到位即可处理;\n- `XXX`:也许某天会修复的问题,优先级最低,不作承诺。\n\n请选择与紧急程度匹配的标签,让浏览代码的人一眼分清「发布阻塞」和「有空再说」。\n\n### 逐字记录类型(`ts type-equiv`)\n\n[子系统](subsystems/README.md)页面会把与源码等价的声明及其原始 JSDoc 一并粘贴,让读者看到确切类型定义和源码约定。为防止粘贴内容在源码变化时漂移,请将其围栏为 ` ```ts type-equiv `(而不是 ` ```ts `),并在 `scripts/type-equiv.manifest.json` 中登记它镜像的源文件和符号:\n\n```json\n{ \"doc\": \"docs/subsystems/session.md\", \"symbol\": \"SessionEvent\", \"source\": \"packages/core/session/src/types.ts\" }\n```\n\n`pnpm run verify-type-equiv`(`doc-sync` 的一环)随后通过 TypeScript 解析器从源码提取该符号的声明及其附带的 JSDoc,并断言代码块同时匹配两者。对于不应把实现体写进目录的类,请使用 ` ```ts public-api ` 并设置 `\"projection\": \"public-api\"`;门禁检查的投影会保留公共字段、构造函数、访问器、方法以及类和成员的原始 JSDoc,同时省略实现体和私有或受保护成员。比对会忽略空白和非 JSDoc 注释,但要求保留每条原始 JSDoc(包括成员文档),让读者同时看到源码约定和确切类型定义。该门禁按文档、符号和投影,在主块与 manifest 条目之间强制 1:1 对应;只有当配对 `.zh.md` 块的完整受跟踪围栏序列与其无后缀兄弟文件按字节一致且顺序相同时,才会复用后者的条目。`doc-typecheck` 对可编译围栏应用同一派生规则,同时跳过两种源码等价围栏的编译,并将其排除在 opt-out 比例之外。当你改动一个已记录的类型声明或其 JSDoc 时,门禁会失败直到你更新粘贴内容;当你增删一个主块时,请在同一个变更里更新 manifest。\n" }, { "role": "user", diff --git a/scripts/test-invariants.spec.ts b/scripts/test-invariants.spec.ts index db3cae7073..ab7370c033 100644 --- a/scripts/test-invariants.spec.ts +++ b/scripts/test-invariants.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it, vi } from 'vitest' -import { Context, FiberState, Service, ValidationError } from 'cordis' -import Loader from '@cordisjs/plugin-loader' -import z from 'schemastery' +import { Context, FiberState, Service, ValidationError } from '@deepseek-ai/cordis' +import Loader from '@deepseek-ai/cordis-plugin-loader' +import z from '@deepseek-ai/schemastery' import InvariantService from '@deepseek-ai/dsh-invariants' import type { InvariantInstaller } from '@deepseek-ai/dsh-invariants' import { packageInvariantOwners } from './package-invariants.ts' @@ -13,7 +13,7 @@ import { usesManualInvariantTree, } from './test-invariants.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { testInvariantProbe: TestInvariantProbe } @@ -39,18 +39,6 @@ function requiredConfig() { }) } -function queuedReadinessConfig( - ctx: Context, - onPublished: (dispose: () => void) => void, -) { - return z.transform(z.any(), () => { - queueMicrotask(() => { - onPublished(ctx.provide(TEST_INVARIANT_READY_SERVICE, true)) - }) - return {} - }, true) -} - function invalidConfigApply(): never { throw new Error('invalid plugin apply executed') } @@ -189,84 +177,55 @@ describe('global test invariant host', () => { expect(apply).not.toHaveBeenCalled() }) - it('disposes invalid config when readiness refresh wins the rejection-handler race', async () => { + it('disposes invalid config after delayed invariant readiness', async () => { await withDelayedFirstCompanion( async ({ started, release }) => { const ctx = new Context() const apply = vi.fn(invalidConfigApply) - let disposeQueuedReadiness: (() => void) | undefined const plugin = { apply, - Config: z.intersect([ - queuedReadinessConfig(ctx, (dispose) => { - disposeQueuedReadiness = dispose - }), - requiredConfig(), - ]), + Config: requiredConfig(), } const fiber = ctx.plugin(plugin, {}) - const firstError = await rejectionOf(fiber) - expectRequiredConfigValidation(firstError) - expect(fiber.state).toBe(FiberState.DISPOSED) + const returnedError = rejectionOf(fiber) + await started + expect(fiber.state).toBe(FiberState.PENDING) expect(apply).not.toHaveBeenCalled() - await started - if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') - disposeQueuedReadiness() release() - await ctx.plugin(TestInvariantProbe) - - const secondError = await rejectionOf(fiber) - expect(secondError).toBe(firstError) + expectRequiredConfigValidation(await returnedError) expect(fiber.state).toBe(FiberState.DISPOSED) expect(apply).not.toHaveBeenCalled() }, ) }) - it('retains a valid plugin failure when readiness wins the initial-probe race', async () => { + it('retains a valid plugin failure after delayed invariant readiness', async () => { await withDelayedFirstCompanion( async ({ started, release }) => { const ctx = new Context() const failure = new Error('valid plugin apply failed') - const applied = deferred() const apply = vi.fn(function validConfigApply() { - applied.resolve() throw failure }) - let disposeQueuedReadiness: (() => void) | undefined const plugin = { apply, - Config: queuedReadinessConfig(ctx, (dispose) => { - disposeQueuedReadiness = dispose - }), + Config: z.object({}), } const fiber = ctx.plugin(plugin, {}) const returnedError = rejectionOf(fiber) - try { - await Promise.all([started, applied.promise]) - expect(fiber.state).toBe(FiberState.FAILED) - expect(apply).toHaveBeenCalledOnce() - expect(ctx.registry.has(plugin)).toBe(true) - expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) + await started + expect(fiber.state).toBe(FiberState.PENDING) + expect(apply).not.toHaveBeenCalled() - if (disposeQueuedReadiness === undefined) throw new Error('queued readiness was not published') - Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) - disposeQueuedReadiness() - release() - - expect(await returnedError).toBe(failure) - expect(fiber.state).toBe(FiberState.FAILED) - expect(apply).toHaveBeenCalledOnce() - expect(ctx.registry.has(plugin)).toBe(true) - expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) - } finally { - Reflect.deleteProperty(fiber.inject, TEST_INVARIANT_READY_SERVICE) - disposeQueuedReadiness?.() - release() - } + release() + expect(await returnedError).toBe(failure) + expect(fiber.state).toBe(FiberState.FAILED) + expect(apply).toHaveBeenCalledOnce() + expect(ctx.registry.has(plugin)).toBe(true) + expect(ctx.registry.get(plugin)?.fibers).toHaveLength(1) }, ) }) diff --git a/scripts/test-invariants.ts b/scripts/test-invariants.ts index 9235f34e46..5b447f5f4a 100644 --- a/scripts/test-invariants.ts +++ b/scripts/test-invariants.ts @@ -6,8 +6,8 @@ */ import { expect } from 'vitest' -import { FiberState, Inject, RegistryService } from 'cordis' -import type { Context, Plugin } from 'cordis' +import { FiberState, Inject, RegistryService, ValidationError } from '@deepseek-ai/cordis' +import type { Context, Plugin } from '@deepseek-ai/cordis' import { AttachmentStore } from '@deepseek-ai/dsh-attachment' import type { ImageAttachmentLimits, @@ -248,22 +248,25 @@ function withInvariantReadiness(plugin: Plugin, callback: PluginCallback): Plugi function joinInvariantStartup( fiber: PluginFiber, invariantReady: Promise, - disposeInitialFailure = false, + disposePendingValidationFailure = false, ): PluginFiber { // RegistryService returns a thenable wrapper whose context still points to // the raw Fiber. Calling inherited await() on the wrapper would return and // assimilate that thenable, accidentally following later plugin startup. const rawFiber = fiber.ctx.fiber - const initialized = disposeInitialFailure - ? rawFiber.await().catch(async (error: unknown) => { - // Config validation is the only failure recorded while a gated fiber - // is initially PENDING. Dispose it even if queued readiness publication - // changes its state before this rejection handler runs. - await rawFiber.dispose() + const readiness = invariantReady.then(async () => { + try { + return await rawFiber.await() + } catch (error) { + // Config resolves only after the readiness injection activates. Dispose + // validation failures owned by an initially pending target; ordinary + // callback failures remain inspectable. + if (disposePendingValidationFailure && error instanceof ValidationError) { + await rawFiber.dispose() + } throw error - }) - : Promise.resolve() - const readiness = initialized.then(() => invariantReady).then(() => rawFiber.await()) + } + }) const joined = Object.create(fiber) as PluginFiber joined.then = readiness.then.bind(readiness) return joined diff --git a/scripts/type-equiv.manifest.json b/scripts/type-equiv.manifest.json index 1a9d998029..11de45ad78 100644 --- a/scripts/type-equiv.manifest.json +++ b/scripts/type-equiv.manifest.json @@ -440,6 +440,11 @@ "symbol": "SessionLocation", "source": "packages/session/session-persistence/src/index.ts" }, + { + "doc": "docs/subsystems/persistence.md", + "symbol": "SessionRawArtifact", + "source": "packages/session/session-persistence/src/index.ts" + }, { "doc": "docs/subsystems/session-query.md", "symbol": "SessionEventSurface", @@ -1620,6 +1625,11 @@ "symbol": "WebBootGraph", "source": "packages/client/modules/src/client/manifest.ts" }, + { + "doc": "docs/subsystems/telemetry.md", + "symbol": "TelemetrySharingStatus", + "source": "packages/session/session-telemetry/src/index.ts" + }, { "doc": "docs/subsystems/telemetry.md", "symbol": "TelemetrySeverity", @@ -1734,6 +1744,101 @@ "doc": "docs/subsystems/core.md", "symbol": "AgentOptions", "source": "packages/core/agent/src/runtime-types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackVersion", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackRating", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackItem", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackListRequest", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackListValue", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackPutRequest", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackDeleteRequest", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackDeleteValue", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackSessionNotFound", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackTargetNotFound", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackVersionConflict", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackNoteBlank", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackNoteTooLarge", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackFailure", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackSuccess", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackRejected", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackListResult", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackPutResult", + "source": "packages/feedback/message-feedback/src/types.ts" + }, + { + "doc": "docs/subsystems/feedback.md", + "symbol": "MessageFeedbackDeleteResult", + "source": "packages/feedback/message-feedback/src/types.ts" } ] } diff --git a/scripts/verify-cordis-config.ts b/scripts/verify-cordis-config.ts index b0aef8ddc9..a70dae4d1c 100644 --- a/scripts/verify-cordis-config.ts +++ b/scripts/verify-cordis-config.ts @@ -165,7 +165,7 @@ function validateEntry(value: unknown, file: string, path: string): void { } recordPlugin(value, file) validateMetadata(value, file, path) - if ((value.group === true || value.name === '@cordisjs/plugin-group') && isUnknownArray(value.config)) { + if ((value.group === true || value.name === '@deepseek-ai/cordis-plugin-group') && isUnknownArray(value.config)) { for (let index = 0; index < value.config.length; index++) { validateEntry(value.config[index], file, `${path}.config[${index}]`) } @@ -175,7 +175,7 @@ function validateEntry(value: unknown, file: string, path: string): void { validateEntry(value.insert[index], file, `${path}.insert[${index}]`) } } - if (value.name !== '@cordisjs/plugin-include') return + if (value.name !== '@deepseek-ai/cordis-plugin-include') return const config = value.config if (!isRecord(config) || !isUnknownArray(config.patches)) return for (let index = 0; index < config.patches.length; index++) { diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 0a202e6142..88b18e7bb0 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -72,6 +72,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/client/ui-conversation': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-tool': { kind: 'none', reason: 'Browser-side Tool presentation layer; renders logged calls without changing model context.' }, 'packages/client/ui-deliverables': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, + 'packages/client/ui-task': { kind: 'none', reason: 'Browser-side read-only projection of ctx.tasks records; dsh-tool-tasks owns the model-facing surface.' }, 'packages/client/ui-slash': { kind: 'none', reason: 'Browser-side UI plugin layer; registers no model surface.' }, 'packages/client/ui-command': { kind: 'indirect', reason: 'The dispatch paths trigger the host command.execute RPC; each command handler\'s host package owns any model-visible effect.' }, 'packages/client/ui-model': { kind: 'indirect', reason: 'Selection routes session.selectModel; the Host snapshots the selection at the next prompt-assembly boundary and owns the model-visible effect.' }, @@ -133,7 +134,6 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/skill/skill-local': { kind: 'indirect', reason: 'The provider backend delegates model rendering to dsh-tool-skill.' }, 'packages/spill/spill': { kind: 'indirect', reason: 'The storage seam delegates model rendering to spill consumers.' }, 'packages/spill/spill-local': { kind: 'indirect', reason: 'The storage backend delegates model rendering to spill consumers.' }, - 'packages/subagent/subagent': { kind: 'indirect', reason: 'The provider registry delegates parent-model rendering to dsh-tool-subagent.' }, 'packages/support/acp-snapshot': { kind: 'none', reason: 'The test harness observes and normalizes transcripts without changing live requests.' }, 'packages/support/agent-loop-testkit': { kind: 'none', reason: 'The test helper mounts services but neither drives nor modifies model requests.' }, 'packages/support/invariants': { kind: 'none', reason: 'The observer validates requests but never rewrites their context.' }, @@ -147,6 +147,7 @@ const SENTENCE_MODEL_EXPERIENCE: Readonly> = { 'packages/tasks/tasks-local': { kind: 'indirect', reason: 'The registry backend delegates model rendering to producer plugins and dsh-tool-tasks.' }, 'packages/examples/acp-demo': { kind: 'indirect', reason: 'The app bundle delegates request composition to dsh-agent-spine-demo and dsh-acp.' }, 'packages/boot/app-boot': { kind: 'indirect', reason: 'Only the loaded plugin tree contributes model context.' }, + 'packages/boot/cmdline': { kind: 'none', reason: 'Resolves the process command line before any session exists; configured rows own every model-visible consequence.' }, 'packages/examples/jsonrpc-demo': { kind: 'indirect', reason: 'Only the externally configured plugin tree contributes model context.' }, 'packages/interaction/permission': { kind: 'indirect', reason: 'The service writes mechanism events rendered by dsh-user-approval and dsh-tool-bash.' }, 'packages/interaction/user-interaction': { kind: 'indirect', reason: 'Model-facing consumers render provider answers and seam errors.' }, diff --git a/scripts/verify-public-repository-links.spec.ts b/scripts/verify-public-repository-links.spec.ts index 372018d7cf..4ff7d5bc62 100644 --- a/scripts/verify-public-repository-links.spec.ts +++ b/scripts/verify-public-repository-links.spec.ts @@ -1,60 +1,45 @@ import { describe, expect, it } from 'vitest' -import { findInternalRepositoryReferences } from './verify-public-repository-links.ts' +import { findUnavailableRepositoryReferences } from './verify-public-repository-links.ts' -describe('public repository link policy', () => { - it('rejects encoded and case-varied internal identities without blocking public repositories', () => { - const internalOwner = ['deepseek', 'harness'].join('-') - const internalRepository = [internalOwner, internalOwner].join('/') - const encodedRepository = internalRepository.replaceAll('-', '%2D').replace('/', '%2F') - const htmlEncodedRepository = internalRepository.replace('/', '/') - const jsonEscapedRepository = internalRepository.replace('/', '\\/') - const unicodeEscapedRepository = internalRepository.replace('/', String.raw`\u002f`) +describe('repository link policy', () => { + it('rejects encoded and case-varied references to the unavailable repository', () => { + const unavailableOwner = ['deepseek', 'ai'].join('-') + const unavailableName = ['deepseek', 'harness', 'sdk'].join('-') + const unavailableRepository = `${unavailableOwner}/${unavailableName}` + const encodedRepository = unavailableRepository.replaceAll('-', '%2D').replace('/', '%2F') + const htmlEncodedRepository = unavailableRepository.replace('/', '/') + const jsonEscapedRepository = unavailableRepository.replace('/', '\\/') + const unicodeEscapedRepository = unavailableRepository.replace('/', String.raw`\u002f`) const source = [ - 'https://github.com/deepseek-ai/deepseek-harness-sdk', - `https://github.com/${internalOwner}/cordis`, - `https://github.com/${internalRepository.toUpperCase()}/issues/1`, + 'https://github.com/deepseek-ai/deepseek-harness', + `https://github.com/${unavailableRepository.toUpperCase()}/issues/1`, `https://github.com/${encodedRepository}/issues/2`, `https://github.com/${htmlEncodedRepository}/issues/3`, `"https:\\/\\/github.com\\/${jsonEscapedRepository}\\/issues\\/4"`, `"https:\\/\\/github.com\\/${unicodeEscapedRepository}\\/issues\\/5"`, - `${internalOwner.toUpperCase()}#6`, + `https://github.com/${unavailableOwner}/cordis`, + `https://github.com/example/${unavailableName}`, ].join('\n') - expect(findInternalRepositoryReferences('subject.md', source)).toEqual([ + expect(findUnavailableRepositoryReferences('subject.md', source)).toEqual([ + { file: 'subject.md', line: 2 }, { file: 'subject.md', line: 3 }, { file: 'subject.md', line: 4 }, { file: 'subject.md', line: 5 }, { file: 'subject.md', line: 6 }, - { file: 'subject.md', line: 7 }, - { file: 'subject.md', line: 8 }, ]) }) - it('allows only the exact audited trusted-publishing repository declarations', () => { - const internalOwner = ['deepseek', 'harness'].join('-') - const internalRepository = [internalOwner, internalOwner].join('/') - const repositoryUrl = `git+https://github.com/${internalRepository}.git` - const manifestLine = ` "url": "${repositoryUrl}",` - const constraintLine = `const repositoryUrl = '${repositoryUrl}'` - const allowedDeclarations = [ - ['native/landlock-run/packages/entry/package.json', manifestLine], - ['native/landlock-run/packages/linux-arm64/package.json', manifestLine], - ['native/landlock-run/packages/linux-x64/package.json', manifestLine], - ['scripts/check-workspace-constraints.ts', constraintLine], - ] as const + it('preserves frozen archived Agent Notes', () => { + const unavailableRepository = ['deepseek-ai', 'deepseek-harness-sdk'].join('/') - for (const [file, source] of allowedDeclarations) { - expect(findInternalRepositoryReferences(file, source)).toEqual([]) - } - - const wrongFile = 'native/landlock-run/package.json' - expect(findInternalRepositoryReferences(wrongFile, manifestLine)).toEqual([{ file: wrongFile, line: 1 }]) - - const manifestFile = 'native/landlock-run/packages/entry/package.json' - const wrongField = ` "homepage": "${repositoryUrl}",` - expect(findInternalRepositoryReferences(manifestFile, wrongField)).toEqual([{ file: manifestFile, line: 1 }]) - - const encodedLine = manifestLine.replace('github.com/', 'github.com\\/') - expect(findInternalRepositoryReferences(manifestFile, encodedLine)).toEqual([{ file: manifestFile, line: 1 }]) + expect(findUnavailableRepositoryReferences( + '.agents/notes/archived/process/historical-record.md', + `https://github.com/${unavailableRepository}`, + )).toEqual([]) + expect(findUnavailableRepositoryReferences( + '.agents/notes/implemented/process/active-record.md', + `https://github.com/${unavailableRepository}`, + )).toEqual([{ file: '.agents/notes/implemented/process/active-record.md', line: 1 }]) }) }) diff --git a/scripts/verify-public-repository-links.ts b/scripts/verify-public-repository-links.ts index e612e6641f..a336b725fc 100644 --- a/scripts/verify-public-repository-links.ts +++ b/scripts/verify-public-repository-links.ts @@ -1,4 +1,4 @@ -/** Reject tracked files that expose the internal repository identity outside audited publishing declarations. */ +/** Reject tracked files that reference an unavailable legacy repository. */ import { execFileSync } from 'node:child_process' import { existsSync, lstatSync, readFileSync, readlinkSync } from 'node:fs' @@ -6,22 +6,13 @@ import { resolve } from 'node:path' import { pathToFileURL } from 'node:url' const root = resolve(import.meta.dirname, '..') -const internalOwner = ['deepseek', 'harness'].join('-') -const internalRepository = [internalOwner, internalOwner].join('/') -const internalIssueShorthand = `${internalOwner}#` -const trustedPublishingRepositoryUrl = `git+https://github.com/${internalRepository}.git` - -/** Exact declarations that intentionally expose the source repository for trusted publishing. */ -const allowedInternalRepositoryLineByFile: Readonly> = { - 'native/landlock-run/packages/entry/package.json': `"url": "${trustedPublishingRepositoryUrl}",`, - 'native/landlock-run/packages/linux-arm64/package.json': `"url": "${trustedPublishingRepositoryUrl}",`, - 'native/landlock-run/packages/linux-x64/package.json': `"url": "${trustedPublishingRepositoryUrl}",`, - 'scripts/check-workspace-constraints.ts': `const repositoryUrl = '${trustedPublishingRepositoryUrl}'`, -} +const unavailableOwner = ['deepseek', 'ai'].join('-') +const unavailableRepositoryName = ['deepseek', 'harness', 'sdk'].join('-') +const unavailableRepository = `${unavailableOwner}/${unavailableRepositoryName}` +const archivedAgentNotePrefix = '.agents/notes/archived/' const namedReferenceCharacters: Readonly> = { hyphen: '-', - num: '#', sol: '/', } @@ -40,8 +31,8 @@ function canonicalReferenceText(source: string): string { .toLowerCase() } -/** One tracked reference to the internal repository. */ -export interface InternalRepositoryReference { +/** One tracked reference to the unavailable repository. */ +export interface UnavailableRepositoryReference { /** Repository-relative file path. */ file: string /** One-based source line. */ @@ -49,20 +40,18 @@ export interface InternalRepositoryReference { } /** - * Locate unaudited internal-repository references in one text file. + * Locate unavailable-repository references in one active text file. * @param file - Repository-relative path used in diagnostics. * @param source - Text to inspect. - * @returns every matching source line. + * @returns every matching source line, excluding frozen archived Agent Notes. */ -export function findInternalRepositoryReferences(file: string, source: string): InternalRepositoryReference[] { - const references: InternalRepositoryReference[] = [] +export function findUnavailableRepositoryReferences(file: string, source: string): UnavailableRepositoryReference[] { + if (file.startsWith(archivedAgentNotePrefix)) return [] + + const references: UnavailableRepositoryReference[] = [] for (const [index, line] of source.split('\n').entries()) { const canonicalLine = canonicalReferenceText(line) - const isAllowedPublishingDeclaration = line.trim() === allowedInternalRepositoryLineByFile[file] - if (!isAllowedPublishingDeclaration - && (canonicalLine.includes(internalRepository) || canonicalLine.includes(internalIssueShorthand))) { - references.push({ file, line: index + 1 }) - } + if (canonicalLine.includes(unavailableRepository)) references.push({ file, line: index + 1 }) } return references } @@ -73,8 +62,8 @@ function trackedFiles(repoRoot: string): string[] { .filter(file => file !== '') } -function scanRepository(repoRoot: string): InternalRepositoryReference[] { - const references: InternalRepositoryReference[] = [] +function scanRepository(repoRoot: string): UnavailableRepositoryReference[] { + const references: UnavailableRepositoryReference[] = [] for (const file of trackedFiles(repoRoot)) { const path = resolve(repoRoot, file) if (!existsSync(path)) continue @@ -82,7 +71,7 @@ function scanRepository(repoRoot: string): InternalRepositoryReference[] { if (!stat.isFile() && !stat.isSymbolicLink()) continue const source = stat.isSymbolicLink() ? readlinkSync(path) : readFileSync(path, 'utf8') if (source.includes('\0')) continue - references.push(...findInternalRepositoryReferences(file, source)) + references.push(...findUnavailableRepositoryReferences(file, source)) } return references } @@ -92,9 +81,9 @@ const isMain = invokedPath !== undefined && import.meta.url === pathToFileURL(re if (isMain) { const references = scanRepository(root) if (references.length === 0) { - console.log('verify-public-repository-links: tracked files expose no unexpected internal repository identity.') + console.log('verify-public-repository-links: tracked files reference no unavailable repository.') } else { - console.error('verify-public-repository-links: unexpected internal repository references found:') + console.error('verify-public-repository-links: unavailable repository references found:') for (const reference of references) console.error(` ${reference.file}:${String(reference.line)}`) process.exitCode = 1 } diff --git a/skills/dsh-customize/SKILL.md b/skills/dsh-customize/SKILL.md deleted file mode 100644 index 5d67af1e51..0000000000 --- a/skills/dsh-customize/SKILL.md +++ /dev/null @@ -1,37 +0,0 @@ ---- -name: dsh-customize -description: Customize or maintain any dsh source checkout — the one powering the current DSH process, the installed `dsh` command, or a sibling dsh/deepseek-harness clone. Use before any requested action that alters such a checkout's files or git state. Read-only questions that only inspect the checkout do not trigger this. Do not edit the personal staging checkout directly. ---- - -# DSH Customize - -Make personal DSH changes in task worktrees and integrate them under the staging lock. Repository instructions still apply. - -## Find staging - -Do not assume a path or branch name. DSH is usually installed from source with a personal staging branch; create one for the user only when none exists. - -1. Inspect `command -v dsh` in the user's launch environment before resolving symlinks. -2. Follow the launcher through the full symlink chain to reach the source checkout, then ask Git for everything else. The `dsh` on PATH is a symlink, usually through a stable `current` symlink into the active staging worktree; resolve the chain physically and take the launcher's parent directory as the checkout. Derive the rest from that checkout rather than from any path convention: `git -C rev-parse --show-toplevel` confirms the checkout root, and `git -C rev-parse --git-common-dir` gives the shared git directory — a linked worktree reports the real clone's, not its own — whose parent is the main clone, the one real clone whose object store every worktree shares. `--git-common-dir` answers relatively for a plain clone, so anchor it against the checkout before use, and resolve it physically: Git reports resolved paths, so comparing one against an unresolved path silently misidentifies the clone whenever a symlink sits anywhere above the checkout, which a symlinked home directory alone is enough to cause. `git -C
worktree list` then enumerates every checkout sharing it. - - This resolves every checkout, so depend on nothing else: not an environment variable, not a container path, not the main clone's location or branch. A checkout whose launcher links straight at it, with no `current` in the chain, resolves the same way. -3. Verify the checkout with Git, then record its branch, tip, status, remotes, worktrees, in-progress operations, and applicable `AGENTS.md` files. -4. Treat the launcher checkout's branch as staging unless the user says otherwise. The installed launcher must resolve to a staging worktree on a staging branch, never the main clone or a task, preparation, review, publication, or detached checkout. Ask if the launcher, checkout, or branch ownership is ambiguous; warn explicitly for a detached HEAD, the main clone, or a non-staging branch. - -## Customize - -1. Create a fresh task branch and worktree from the recorded staging tip, using the repository-required worktree location — default to `.worktrees/` under the repository root unless the repository requires otherwise. Never implement or commit directly on staging. -2. Implement the change, then select and run the repository-required review and checks. If a check fails, fix the cause and rerun it before integration. -3. Test assembled interactive behavior in the Web UI; unit tests and snapshots alone are insufficient. -4. Record the task tip and confirm the task worktree is clean before integration. - -## Integrate under the lock - -1. Resolve the worktree that owns staging and use `/.agents/merge.lock`. Keep it Git-ignored; never remove or replace it. Require `flock`. -2. Acquire the lock, then re-check branch ownership, exact staging tip, clean status, and absence of an in-progress Git operation. If staging moved, unlock and restart discovery against its current owner's lock. -3. Hold the same lock through final precondition checks, `git merge --no-ff`, required post-merge checks, conflict handling, and rollback. -4. If the merge or a post-merge check fails, abort the merge or restore the recorded clean staging tip before unlocking. Never discard unknown user files. -5. Before unlocking, verify staging's branch, commit, clean status, and required checks. Report that evidence and the commands run. -6. Remove the task worktree and branch only when their commits are reachable from staging and no longer needed. - -Use [`dsh-upstream-customization`](../dsh-upstream-customization/SKILL.md) when the user wants to contribute a personal feature upstream. diff --git a/skills/dsh-upgrade/SKILL.md b/skills/dsh-upgrade/SKILL.md deleted file mode 100644 index 119725274c..0000000000 --- a/skills/dsh-upgrade/SKILL.md +++ /dev/null @@ -1,48 +0,0 @@ ---- -name: dsh-upgrade -description: Upgrades a source-installed, personally customized DSH checkout to upstream master while preserving local changes and an unchanged rollback worktree. Use when the user asks to update or upgrade DSH. ---- - -# DSH Upgrade - -Prepare and validate the upgrade in a fresh staging worktree of the main clone, leave the worktree the installed launcher currently uses unchanged, then atomically repoint the stable `current` symlink once. Read and follow [`dsh-customize`](../dsh-customize/SKILL.md) before starting; it owns checkout discovery and lock handling. - -## Layout - -Resolve the layout, never assume it. [`dsh-customize`](../dsh-customize/SKILL.md) owns the procedure: follow the PATH launcher to the staging worktree, then derive the main clone from that checkout with Git. One resolution covers every checkout, so this workflow needs no special case and depends on no environment variable. - -The resolved layout is one container directory `` holding each staging checkout as a git worktree `/staging-` on branch `dsh-staging/`, plus the stable symlink `/current` pointing at the active one; the PATH launcher links to `/current/bin/dsh`, so it resolves PATH -> `current` -> staging worktree. The main clone is the one real clone whose object store every worktree shares, and is never a launcher target. It may live inside `` or anywhere else on disk, on any branch, with remotes that may point at a fork — so treat it strictly as the object store and worktree host, and take authoritative upstream from step 1 instead. Cutover repoints `current` alone, so the PATH launcher itself never moves. The main clone's `.git/info/exclude` is inherited by every linked worktree, so one `.agents/merge.lock` entry there excludes the lock in all of them. When the launcher links straight at a worktree with no `current` in the chain, the same resolution finds it, and cutover creates `current` and repoints PATH to `current/bin/dsh` as a one-time migration. - -## Names - -One upgrade attempt uses one UTC basic timestamp `YYYYMMDDTHHMMSSZ` for all names: - -- new staging worktree: `/staging-`; -- preparation branch: `dsh-upgrade/prepare-`; -- installed staging branch: `dsh-staging/`; -- fetched upstream ref: `refs/dsh-upgrade/upstream-`; -- recovery ref: `refs/dsh-upgrade/recovery-`; -- recorded `current` target before cutover: the old staging worktree path, kept for symlink rollback. - -The worktree name is always `staging-` under ``, never derived from the current staging directory name, so successive upgrades stay in one place and do not accumulate timestamps. The preparation branch and private refs are local-only and must never be pushed. Before starting, reject a current staging branch named exactly `dsh-staging`, because Git cannot also create `dsh-staging/`; require the user to choose a non-conflicting staging namespace rather than silently renaming it. If the new staging worktree path exists, resume only when it is a clean worktree of this main clone whose recorded old tip, upstream ref, recovery ref, and named branches exactly match this attempt; otherwise stop. Never add an ad hoc suffix or delete an unknown directory. - -## Upgrade - -1. Resolve the installed launcher, its staging worktree and branch, the main clone, the current DSH process source, and authoritative upstream. Record exact tips, paths, clean status, remotes, dependencies, worktrees, and in-progress Git operations. Require the installed staging worktree to be clean and its `.agents/merge.lock` to exist and be Git-excluded. Never stash automatically. -2. Treat the staging worktree behind the installed launcher as immutable for the whole attempt: do not touch its branch, HEAD, index, tracked or untracked files, dependencies, worktree registration, or lock file. Fetching into the shared main clone and creating new branches, worktrees, and private refs there are allowed because they are append-only and never alter the old worktree's checkout; opening and holding the existing lock is the only operation on the old worktree. -3. Allocate the timestamp and new staging worktree path. Acquire the installed worktree's existing `.agents/merge.lock`, repeat every precondition, and keep it through preparation, validation, and the `current` cutover. If staging moves while waiting, unlock and restart with a new timestamp; remove only attempt artifacts that this run created and verified as disposable. -4. In the main clone, create `refs/dsh-upgrade/recovery-` at the recorded old staging tip and `dsh-upgrade/prepare-` from that tip. Fetch exact authoritative upstream `master` into `refs/dsh-upgrade/upstream-` and record its object ID. Add a fresh worktree `/staging-` checked out on the preparation branch. Confirm the main clone's `.git/info/exclude` excludes `.agents/merge.lock`, which the new worktree inherits. -5. Inspect the Git log and commit ranges between the staging base, old staging tip, and fetched upstream tip. Identify incoming upstream changes, personal commits to preserve, likely duplicates, and conflict-prone areas before rebasing. -6. In the new worktree, rebase the preparation branch onto the fetched upstream commit. Preserve intentional customizations and drop behavior already upstream. If upstream contains the customization and its remaining local diff only documents that customization, prefer upstream and drop the documentary diff rather than retaining a stale local account. Preserve documentation only when it adds current, independently useful behavior or rules absent upstream. Abort without changing the installed launcher when resolution is uncertain. -7. Install dependencies in the new worktree, review the resulting diff, and run the repository-required checks. Fix failures and rerun affected checks. Test the new worktree's `bin/dsh` directly. -8. Point `dsh-staging/` at the validated prepared tip and check it out in the new worktree. Ensure its `.agents/merge.lock` exists (Git-excluded through the shared main-clone exclude). Verify its branch, exact commit, clean status, remotes, dependencies, and absence of in-progress Git operations, then smoke its `bin/dsh` from a clean temporary workspace. The preparation branch remains temporary; the timestamped staging branch owns the installed commit. -9. Recheck the old worktree, existing lock, launcher, `current`, main clone, new worktree, refs, and exact tips. Record `current`'s pre-cutover target, then repoint `current` at the new staging worktree in one atomic swap with `ln -sfn` (the `-n` stops `ln` from dereferencing the existing directory symlink and writing the link inside the old worktree; `mv` behaves the same way and is unusable). Leave the PATH launcher alone once it already resolves through `current`; if a legacy install still links PATH straight at a worktree, create `current` and repoint PATH to `current/bin/dsh` as a one-time migration here. The `current` target must be a clean staging worktree on a staging branch and must never be the main clone or a preparation, feature, review, publication, or detached checkout. Smoke the installed `dsh` command from a clean temporary workspace. -10. On failure before the `current` cutover, leave `current`, the launcher, and the old worktree unchanged and remove only verified attempt artifacts created by this run (including the new worktree registration if empty). On failure during or after cutover, inspect `current`'s observed target before acting; if cutover did not verify, atomically repoint `current` back to its recorded pre-cutover target with `ln -sfn` and verify that `dsh` starts from the unchanged old staging worktree. This rollback is the sole exception allowing `current` to return to the old staging worktree. Never retry a side-effecting operation blindly. -11. Release the old worktree's lock and tell the user to restart DSH through the installed launcher. The current process may continue from the old worktree, but no operation may mutate or remove it until the restarted process proves that it runs from `dsh-staging/` and the user confirms stability. Avoid customization integration during this confirmation window; if rollback is required after new work lands, reconcile that work explicitly rather than silently stranding it. -12. After confirmation, remove the preparation branch if no process uses it. Keep the old staging worktree and branch, the recovery ref, and the recorded pre-cutover `current` target as rollback until the user explicitly approves their removal; leave the actual `git worktree remove` and directory deletion to the user. Report old, upstream, prepared, and new staging commits; both staging worktree paths and branches; the main clone path; process-source evidence; the `current` pre-cutover target and cutover; commands and checks; final status; recovery ref; and retained rollback artifacts. - -The installed launcher always resolves through `current` to a staging worktree, never the main clone. Upgrade preparation adds a new worktree that shares the main clone's object store while leaving the old worktree's checkout untouched; cutover is one atomic `current` repoint to the separately validated timestamped staging worktree, and the PATH launcher never moves. - -## Recommend upstream candidates - -After a successful upgrade, load [`dsh-upstream-customization`](../dsh-upstream-customization/SKILL.md) and classify each remaining personal customization by its rules. For each candidate, explain its classification and upstream value and recommend whether to propose it, then ask which named candidate, if any, the user wants to upstream. The answer selects a candidate to start that skill's publication workflow; it is not publishing approval, which that workflow still requires. diff --git a/skills/dsh-upstream-customization/SKILL.md b/skills/dsh-upstream-customization/SKILL.md deleted file mode 100644 index 7ec1618db3..0000000000 --- a/skills/dsh-upstream-customization/SKILL.md +++ /dev/null @@ -1,27 +0,0 @@ ---- -name: dsh-upstream-customization -description: Classifies personal DSH customizations for upstream contribution and, after explicit per-feature approval, rebuilds one on upstream master and opens a draft pull request. Use when the user asks to contribute, publish, or upstream a local DSH change, or asks whether one is worth proposing. ---- - -# DSH Upstream Customization - -Classify and propose personal customizations upstream one feature at a time. - -## Classify - -- **Definitely propose:** bug fixes. -- **Propose:** additive, non-conflicting features implemented as plugins; visual improvements. -- **Do not propose without maintainer approval:** intrusive changes that alter existing architecture, core behavior, or broad contracts. -- Explain the classification and upstream value. If unsure whether a change is intrusive, treat it as intrusive. - -Classification and a recommendation are not publishing approval. Obtain explicit user approval naming one feature before pushing or opening a PR; approval for another feature, an upgrade, or local integration does not apply. - -## Publish an approved feature - -1. Fetch current upstream `master`, then rebuild only the approved feature on a fresh branch and worktree at that exact commit. Never publish the personal staging branch or unrelated customizations. -2. Follow repository instructions for implementation, review, testing, disclosure, PR writing, and pre-push checks. Fix failures and rerun the affected checks before publishing. -3. Review the outgoing commits and diff against upstream. Confirm they contain only the approved feature, no credentials or personal data, and a clean worktree. -4. Reconfirm the approved feature name and publishing target before the first push. Do not infer authorization from earlier local work. -5. Push only that branch and open only a draft PR. Keep its description synchronized with later changes. -6. For a Web UI feature, attach a screenshot or GIF from the assembled application after removing credentials and personal data. -7. Report the upstream base and branch commits, commands and checks run, pushed branch, and draft PR URL. diff --git a/tsconfig.base.json b/tsconfig.base.json index 0523b378d9..a08b438f3a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -28,16 +28,15 @@ // not declaration path aliases, keep each package/vendor source compiled // under its own tsconfig boundary. "paths": { - "cordis": ["./vendor/cordis/src"], - "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"], - "@cordisjs/plugin-hmr": ["./vendor/hmr/src"], - "@cordisjs/plugin-logger-console": ["./vendor/logger-console/src"], + "@deepseek-ai/cordis": ["./vendor/cordis/src"], + "@deepseek-ai/cosmokit": ["./vendor/cosmokit/src"], + "@deepseek-ai/schemastery": ["./vendor/schemastery/src"], + "@deepseek-ai/cordis-plugin-loader": ["./vendor/loader/src"], + "@deepseek-ai/cordis-plugin-include": ["./vendor/include/src"], + "@deepseek-ai/cordis-plugin-group": ["./vendor/group/src"], + "@deepseek-ai/cordis-plugin-timer": ["./vendor/timer/src"], + "@deepseek-ai/cordis-plugin-hmr": ["./vendor/hmr/src"], + "@deepseek-ai/cordis-plugin-logger-console": ["./vendor/logger-console/src"], "@deepseek-ai/node-addon-landlock-run": ["./native/landlock-run/packages/entry/src/index.ts"], "@deepseek-ai/dsh-invariants": ["./packages/support/invariants/src/index.ts"], "@deepseek-ai/dsh-typert-registry": ["./packages/typert/registry/src/index.ts"], @@ -72,6 +71,7 @@ "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"], "@deepseek-ai/dsh-commands/brand": ["./packages/interaction/commands/src/brand.ts"], "@deepseek-ai/dsh-commands/types": ["./packages/interaction/commands/src/types.ts"], + "@deepseek-ai/dsh-tasks/brand": ["./packages/tasks/tasks/src/brand.ts"], "@deepseek-ai/dsh-compact/checkpoint": ["./packages/compact/compact/src/checkpoint.ts"], "@deepseek-ai/dsh-compact/types": ["./packages/compact/compact/src/types.ts"], "@deepseek-ai/dsh-tools/presentation": ["./packages/core/tools/src/presentation.ts"], @@ -132,6 +132,8 @@ // group prefix with a dedicated wildcard per group instead. "@deepseek-ai/dsh-host-*/invariant": ["./packages/host/*/src/invariant.ts"], "@deepseek-ai/dsh-client-*/invariant": ["./packages/client/*/src/invariant.ts"], + "@deepseek-ai/dsh-headless/startup": ["./packages/bundle/headless/src/startup.ts"], + "@deepseek-ai/dsh-web-app/startup": ["./packages/bundle/web-app/src/startup.ts"], "@deepseek-ai/dsh-client-*/client": ["./packages/client/*/src/client"], // One wildcard maps every @deepseek-ai/dsh- to its source. Package // dir names are unique across groups, so first-on-disk-wins resolution is @@ -177,6 +179,7 @@ "@deepseek-ai/dsh-client-ui-permission": ["./packages/client/ui-permission/src"], "@deepseek-ai/dsh-client-ui-skill": ["./packages/client/ui-skill/src"], "@deepseek-ai/dsh-client-ui-subagent": ["./packages/client/ui-subagent/src"], + "@deepseek-ai/dsh-client-ui-task": ["./packages/client/ui-task/src"], "@deepseek-ai/dsh-client-ui-plan": ["./packages/client/ui-plan/src"], "@deepseek-ai/dsh-client-ui-question": ["./packages/client/ui-question/src"], "@deepseek-ai/dsh-client-ui-trajectory": ["./packages/client/ui-trajectory/src"], diff --git a/tsconfig.client.json b/tsconfig.client.json index 632f6a84a7..fce7d45b7b 100644 --- a/tsconfig.client.json +++ b/tsconfig.client.json @@ -66,6 +66,7 @@ { "path": "./packages/client/ui-command" }, { "path": "./packages/client/ui-skill" }, { "path": "./packages/client/ui-subagent" }, + { "path": "./packages/client/ui-task" }, { "path": "./packages/client/ui-goal" }, { "path": "./packages/client/ui-model" }, { "path": "./packages/client/ui-agent-preset" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index d9bf1c29e4..2beed0b1c3 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -13,7 +13,8 @@ "apps/web/tests/declared-reasoning.e2e.ts", "apps/web/tests/support.ts", "apps/web/tests/scaffold-hermetic.e2e.ts", - "apps/web/tests/core-web-profile.snapshot.ts", + "apps/web/tests/minimal-preset.snapshot.ts", + "apps/web/tests/message-feedback-protocol.snapshot.ts", "apps/web/tests/live-interactions.e2e.ts", "apps/web/tests/question-composer.e2e.ts", "apps/web/tests/approval-composer.e2e.ts", @@ -52,6 +53,7 @@ "apps/web/tests/agent-preset-authoring.e2e.ts", "apps/web/tests/shipped-composition.e2e.ts", "apps/web/tests/goal-bar.e2e.ts", + "apps/web/tests/feedback-command.e2e.ts", "apps/web/tests/startup-auto-selection.e2e.ts", "apps/web/tests/produced-files.e2e.ts", "apps/web/tests/produced-file-mentions.e2e.ts", @@ -59,6 +61,7 @@ "apps/web/tests/subagent-interrupt.e2e.ts", "apps/web/tests/subagent-interrupt-ui.e2e.ts", "apps/web/tests/sidebar-subagent-activity.e2e.ts", + "apps/web/tests/background-task-list.e2e.ts", "apps/web/tests/bash-abort-row.e2e.ts", "apps/web/tests/skill-tool-row.e2e.ts", "apps/web/tests/turn-tail-actions.e2e.ts", @@ -133,6 +136,7 @@ { "path": "./packages/storage/storage-json" }, { "path": "./packages/storage/storage-sqlite" }, { "path": "./packages/storage/storage-domain" }, + { "path": "./packages/feedback/message-feedback" }, { "path": "./packages/workspace/workspace" }, { "path": "./packages/session/session-title" }, { "path": "./packages/session/session-title-llm" }, @@ -222,6 +226,7 @@ { "path": "./packages/bundle/headless" }, { "path": "./packages/bundle/web-app" }, { "path": "./packages/boot/app-boot" }, + { "path": "./packages/boot/cmdline" }, { "path": "./packages/scaffold/server" }, { "path": "./packages/examples/jsonrpc-demo" }, { "path": "./packages/support/llm-replay" }, @@ -253,7 +258,6 @@ { "path": "./packages/preset/persona" }, { "path": "./packages/guard/repeat-tool-guard" }, { "path": "./packages/self-modification/tool-cordis" }, - { "path": "./packages/self-modification/repository-plugin" }, { "path": "./packages/hooks/hook-protocol" }, { "path": "./packages/hooks/hooks-claude" }, { "path": "./packages/hooks/hooks-codex" }, diff --git a/tsdown.config.ts b/tsdown.config.ts index 2042e81db3..6dd12f3eb4 100644 --- a/tsdown.config.ts +++ b/tsdown.config.ts @@ -17,7 +17,7 @@ export default defineConfig(({ env }) => { const client = isBuildFaceClient(env?.DSH_BUILD_FACE) return { workspace: ['vendor/*', 'packages/*/*', 'apps/cli'], - entry: client ? '' : ['lib/types/{index,invariant}.js'], + entry: client ? '' : ['lib/types/{index,invariant,startup}.js'], outDir: 'lib', format: ['esm'], platform: 'node', diff --git a/vendor/README.md b/vendor/README.md index 75f93b2b88..0d889e87fd 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. 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. +All vendored packages are **renamed into the `@deepseek-ai` scope** (`cordis` → `@deepseek-ai/cordis`, `@cordisjs/plugin-` → `@deepseek-ai/cordis-plugin-`): every harness package declares `cordis` as a peer dependency, so publishing the harness publishes this framework layer too, and a publication under the upstream names would squat them on the registry. Directory names and upstream version numbers are deliberately unchanged, so the manifest below still reads as an upstream snapshot. `pnpm-workspace.yaml#linkWorkspacePackages` makes those preserved semver ranges resolve these pinned workspaces, including imports from built `lib/`. 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('@deepseek-ai/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). @@ -10,17 +10,17 @@ This file covers the manifest, the local-modification log, and the procedure for Upstream workspace: `cordis-workspace` (local checkout: `~/repos/cordis-workspace`). -| Directory | npm name | Version | Upstream repo | Commit | -|---|---|---|---|---| -| `cosmokit/` | `cosmokit` | 1.8.1 | https://github.com/deepseek-harness/cosmokit | `16f6fc058ade66e8ac5da0033d35a8d0f279f544` | -| `schemastery/` | `schemastery` | 3.18.0 | https://github.com/deepseek-harness/schemastery (`packages/core`) | `e67cee00ad725bd1534aee930a979ea3eec6f698` | -| `cordis/` | `cordis` | 4.0.0-rc.7 | https://github.com/cordiverse/cordis (`packages/core`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` | -| `loader/` | `@cordisjs/plugin-loader` | 1.0.0-rc.5 | https://github.com/cordiverse/cordis (`packages/loader`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` | -| `include/` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | -| `group/` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | -| `timer/` | `@cordisjs/plugin-timer` | 1.1.2 | https://github.com/deepseek-harness/cordis (`packages/timer`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | -| `hmr/` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | -| `logger-console/` | `@cordisjs/plugin-logger-console` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/logger-console`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| Directory | npm name | Upstream name | Version | Upstream repo | Commit | +|---|---|---|---|---|---| +| `cosmokit/` | `@deepseek-ai/cosmokit` | `cosmokit` | 1.8.1 | https://github.com/deepseek-harness/cosmokit | `16f6fc058ade66e8ac5da0033d35a8d0f279f544` | +| `schemastery/` | `@deepseek-ai/schemastery` | `schemastery` | 3.18.0 | https://github.com/deepseek-harness/schemastery (`packages/core`) | `e67cee00ad725bd1534aee930a979ea3eec6f698` | +| `cordis/` | `@deepseek-ai/cordis` | `cordis` | 4.0.0-rc.7 | https://github.com/cordiverse/cordis (`packages/core`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` | +| `loader/` | `@deepseek-ai/cordis-plugin-loader` | `@cordisjs/plugin-loader` | 1.0.0-rc.5 | https://github.com/cordiverse/cordis (`packages/loader`) | `56b3d4f725681cf4556c1a8695a709cc3b6eed74` | +| `include/` | `@deepseek-ai/cordis-plugin-include` | `@cordisjs/plugin-include` | 1.0.4 | https://github.com/deepseek-harness/cordis (`packages/include`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| `group/` | `@deepseek-ai/cordis-plugin-group` | `@cordisjs/plugin-group` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/group`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| `timer/` | `@deepseek-ai/cordis-plugin-timer` | `@cordisjs/plugin-timer` | 1.1.2 | https://github.com/deepseek-harness/cordis (`packages/timer`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| `hmr/` | `@deepseek-ai/cordis-plugin-hmr` | `@cordisjs/plugin-hmr` | 1.0.15 | https://github.com/deepseek-harness/cordis (`packages/hmr`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | +| `logger-console/` | `@deepseek-ai/cordis-plugin-logger-console` | `@cordisjs/plugin-logger-console` | 1.0.0 | https://github.com/deepseek-harness/cordis (`packages/logger-console`) | `abb0a307cb1d3b0947f455d590cf5ba922d4caa4` | Third-party dependencies of the vendored packages stay on npm: `@standard-schema/spec`, `js-yaml`, `chokidar`, `picomatch`, `@babel/code-frame`, `supports-color`, `node-addon-require-builtin`. @@ -37,14 +37,17 @@ 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 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/boot/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, contain sibling-start failures after their owning tree is disposed, undo changes and additions on live-update 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/boot/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. Module watches realpath their existing base directory, attach change listeners before declaring the service ready, and use that spelling for Node module-cache identity; exact config watches realpath the deepest existing watch ancestor and restore the missing suffix. Those native paths prevent Windows short-name aliases from colliding with long-form libuv event paths while exact-config callbacks keep the requested filename. 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/boot/app-boot/tests/hmr-config.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. A transaction-owned `pnpm` wrapper and exported `PNPM_CONFIG_IGNORE_WORKSPACE` make pnpm's nested Git-package install reinvoke the same bundled entry outside an enclosing source workspace. The child retains `PNPM_HOME` for pnpm data while removing that directory from lifecycle `PATH`, and prioritizes `.CMD` in `PATHEXT` so a later inherited pnpm executable cannot outrank the wrapper on Windows. The temporary command directory is removed after the child settles. 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/boot/app-boot/tests/repository-cache.spec.ts`, including a keyless local-Git `prepack` whose package is excluded from an enclosing pnpm lockfile, obtains both its build and prepare commands from declared dependencies, and rejects an inherited shadow 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 an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays 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/boot/app-boot/tests/config-reload.spec.ts`. -13. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. -14. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. -15. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. +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 an empty profile root with each bundle's patch layer, the profile's and the home-level `cordis.patch.yml`, and any `--patch` overlays 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/boot/app-boot/tests/config-reload.spec.ts`. +12. **`include/src/index.ts` serialized child-tree mutation and `hmr/src/index.ts` main-watcher initial-scan suppression**: every Include child-tree mutation (initial apply, refresh, `internal/update` patch re-application) runs through one per-Include queue, because the group's transactional `update` is not reentrant — two concurrent applies interleave create and rollback on the same entries and strand the Include fiber without ever settling. The HMR main watcher passes `ignoreInitial: true`: the initial scan re-announced files boot had just consumed, and its `add` for a config file refreshed an Include mid-initial-apply; once serialized, a failing initial apply's rollback disposed HMR, whose teardown drain waited on the queued refresh sitting behind that same apply — a deadlock that exited 13 with no diagnostic. `registerConfig()` keeps its own `ignoreInitial: false` watcher because a user patch layer present at registration must apply once. Covered by the patch-overlay boot-failure built-bin case in `apps/cli/tests/built-bin.e2e.ts`. +13. **`include/src/index.ts` `writeTask` type**: widened the optional `writeTask?: NodeJS.Timeout` property to `NodeJS.Timeout | undefined` — the debounced writer assigns `undefined` on flush, which `exactOptionalPropertyTypes` rejects on a plain optional. Type-only; no behavior change. +14. **`include/src/index.ts` durable debounced writes**: serialized and tracked config-file writes, retried transient `EACCES`/`EBUSY`/`EPERM` rename failures with a bounded backoff, observed asynchronous timer rejections, and drained the latest write during Include teardown. Windows can briefly retain a destination handle after a Loader child disposes; the upstream fire-and-forget rename escaped as an unhandled rejection and could lose the persisted `disabled` state. A terminal failure is logged by the asynchronous writer and remains on the queue so `Include.stop()` rethrows it instead of silently declaring persistence complete; Cordis's ordinary fiber teardown retains its separate error-containment contract. Covered by `packages/host/directory-picker-auto/tests/loader-composition.spec.ts` with injected transient and terminal rename failures. +15. **Lazy Loader config resolution across `cordis/src/{events,fiber}.ts`, `loader/src/{index,config/entry}.ts`, `include/src/index.ts`, and `hmr/src/index.ts`**: ports [cordiverse/cordis#41](https://github.com/cordiverse/cordis/pull/41), retaining raw fiber config and resolving it through `internal/config` only after declared injections are active. Provider replacement re-resolves the raw expression, pending updates retain it, and HMR transfers it. Resolution applies only to the entry root, so child plugins mounted by a row keep caller-owned config identity. Include adds a static entry-config resolver so its own options interpolate while nested row `!!js` nodes remain deferred. Deferred failures retain the owning row diagnostic, and tree teardown does not persist failure-driven self-disposal. Covered by `packages/boot/app-boot/tests/{app-boot,user-patches}.spec.ts`, `packages/boot/cmdline/tests/cmdline.spec.ts`, `apps/cli/tests/web-agent-presets.e2e.ts`, and the built custom-profile cases in `apps/cli/tests/built-bin.e2e.ts`. +16. **In-memory Loader entry activation in `loader/src/config/entry.ts`**: an invocation can activate a row shipped with `disabled: true` without mutating its serialized options. The override belongs to the mounted entry object, survives Include config reapplication, respects disabled ancestors, and disappears with the entry. Covered by `packages/boot/cmdline/tests/cmdline.spec.ts` and `apps/web/tests/hmr-live.e2e.ts`. +17. **`@deepseek-ai` rescope**: every vendored manifest `name`, every internal dependency entry among the vendored set, and every module specifier that reaches them use the scoped names in the manifest table's `npm name` column. Directory names, version numbers, and dependency ranges are unchanged, and no upstream runtime identifier is renamed — `Symbol.for('schemastery')` and Schemastery's `vendor:` metadata field keep their upstream values. Re-apply with `pnpm run rescope-vendor --apply` after a sync; the table's two name columns are the mapping, restated for consumers in [docs/rescope.md](../docs/rescope.md). +18. **`cordis/package.json` publishes `src`**: added `src` to the `files` list, joining the other eight vendored packages. Cordis declares `"./src/*": "./src/*"` in its exports, so a tarball without `src` publishes an export map pointing at absent files; the release change judgement also reads `files` to decide whether a diff reaches the payload, and a package whose only published paths are build output has no tracked path to match. ## Sync procedure diff --git a/vendor/cordis/bin.js b/vendor/cordis/bin.js index 9aecc7ce10..e5ee5224f7 100755 --- a/vendor/cordis/bin.js +++ b/vendor/cordis/bin.js @@ -1,15 +1,15 @@ #!/usr/bin/env node -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import { pathToFileURL } from 'node:url' -import Loader from '@cordisjs/plugin-loader' +import Loader from '@deepseek-ai/cordis-plugin-loader' const ctx = new Context() ctx.baseUrl = pathToFileURL(process.cwd()).href + '/' await ctx.plugin(Loader) await ctx.loader.create({ - name: '@cordisjs/plugin-include', + name: '@deepseek-ai/cordis-plugin-include', config: { path: './cordis.yml', }, diff --git a/vendor/cordis/package.json b/vendor/cordis/package.json index 80a327c2dd..66a5f1593d 100644 --- a/vendor/cordis/package.json +++ b/vendor/cordis/package.json @@ -1,8 +1,15 @@ { - "name": "cordis", + "name": "@deepseek-ai/cordis", "description": "Meta-Framework for Modern JavaScript Applications", - "version": "4.0.0-rc.7", - "private": true, + "version": "4.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "vendor/cordis" + }, "sideEffects": false, "type": "module", "main": "lib/index.js", @@ -20,24 +27,25 @@ "lib/index.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", - "bin.js" + "bin.js", + "src" ], "author": "Shigma ", "license": "MIT", "peerDependencies": { - "@cordisjs/plugin-include": "^1.0.4", - "@cordisjs/plugin-loader": "^1.0.0-rc.5" + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^" }, "peerDependenciesMeta": { - "@cordisjs/plugin-include": { + "@deepseek-ai/cordis-plugin-include": { "optional": true }, - "@cordisjs/plugin-loader": { + "@deepseek-ai/cordis-plugin-loader": { "optional": true } }, "dependencies": { "@standard-schema/spec": "^1.1.0", - "cosmokit": "^1.8.1" + "@deepseek-ai/cosmokit": "workspace:^" } } diff --git a/vendor/cordis/src/context.ts b/vendor/cordis/src/context.ts index 919ae65db3..0488423088 100644 --- a/vendor/cordis/src/context.ts +++ b/vendor/cordis/src/context.ts @@ -1,4 +1,4 @@ -import type { Dict } from 'cosmokit' +import type { Dict } from '@deepseek-ai/cosmokit' import { EventsService } from './events.ts' import { LoggerService } from './logger.ts' import { ReflectService } from './reflect.ts' diff --git a/vendor/cordis/src/events.ts b/vendor/cordis/src/events.ts index 2e862c97d4..e9bd85e3e1 100644 --- a/vendor/cordis/src/events.ts +++ b/vendor/cordis/src/events.ts @@ -1,5 +1,5 @@ -import { defineProperty } from 'cosmokit' -import type { Promisify } from 'cosmokit' +import { defineProperty } from '@deepseek-ai/cosmokit' +import type { Promisify } from '@deepseek-ai/cosmokit' import { Context } from './context.ts' import { Fiber, FiberState } from './fiber.ts' import { DisposableList, symbols } from './utils.ts' @@ -331,6 +331,12 @@ export interface Events { 'internal/plugin'(fiber: Fiber): void /** A fiber changed lifecycle state; receives the fiber and its previous state. */ 'internal/status'(fiber: Fiber, oldValue: FiberState): void + /** + * Resolve raw plugin config after the fiber's injections become active. + * @param config - the raw config for this activation. + * @mode waterfall + */ + 'internal/config'(this: Fiber, config: any, next: () => any): any /** 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. */ diff --git a/vendor/cordis/src/fiber.ts b/vendor/cordis/src/fiber.ts index 5511b39036..38a3197e29 100644 --- a/vendor/cordis/src/fiber.ts +++ b/vendor/cordis/src/fiber.ts @@ -1,5 +1,5 @@ -import { defineProperty, isNullable } from 'cosmokit' -import type { Awaitable, Dict } from 'cosmokit' +import { defineProperty, isNullable } from '@deepseek-ai/cosmokit' +import type { Awaitable, Dict } from '@deepseek-ai/cosmokit' import { Context } from './context.ts' import type { Plugin } from './registry.ts' import { buildOuterStack, composeError, DisposableList, getTraceable, isConstructor, isObject, symbols } from './utils.ts' @@ -188,6 +188,8 @@ export class Fiber { public readonly ctx: Context /** The validated plugin config (updated by `update()`). */ public config: any + /** The raw plugin config, re-resolved before each activation. */ + public _config: any /** Current lifecycle state; transitions emit `internal/status`. */ public state = FiberState.PENDING /** Dispose this fiber: unload the plugin, then settle once cleanup finished. */ @@ -224,6 +226,7 @@ export class Fiber { public runtime: Plugin.Runtime | null, getOuterStack: () => string[], ) { + this._config = config const collect = (dispose: Disposable) => { this._disposables.push(dispose) } @@ -259,16 +262,8 @@ export class Fiber { collect, } - let shouldRefresh = false this.dispose = parent.fiber.effect(() => { const remove = runtime.fibers.push(this) - try { - this.config = resolveConfig(runtime, config) - shouldRefresh = true - } catch (error) { - this.ctx.logger.error(error) - this._error = error - } return async () => { this.uid = null emitPluginDisposed(this.context, this) @@ -320,7 +315,7 @@ export class Fiber { for (const name of Object.keys(this.inject)) { this._checkImpl(name) } - if (shouldRefresh) this._refresh() + this._refresh() } } else { this.uid = 0 @@ -643,6 +638,11 @@ export class Fiber { }) } + private _resolveConfig(config: any) { + config = this.context.waterfall(this, 'internal/config', config, () => config) + return this.runtime ? resolveConfig(this.runtime, config) : config + } + private async _reload() { this.store = { ...this._store } const oldEpoch = this._runner.epoch @@ -652,7 +652,9 @@ export class Fiber { // the load. Do not run plugin code for a stale epoch; the state update // below will drain any effects collected while the fiber was PENDING. if (this._runner.epoch === oldEpoch) { + this.config = this._resolveConfig(this._config) await this._execute(this._runner) + this._error = undefined } } catch (reason) { // impl guarantees that the error is non-null (?) @@ -733,7 +735,16 @@ export class Fiber { */ update(config: any, noSave = false) { this.assertActive() - config = resolveConfig(this.runtime!, config) + this._config = config + if (this.state !== FiberState.ACTIVE) { + // Config resolution may access injected services, so defer it until the + // fiber can activate. + this._error = undefined + this._setEpoch(INACTIVE) + this._refresh() + return + } + config = this._resolveConfig(config) return this.context.waterfall(this, 'internal/update', config, noSave, () => { this.config = config this._error = undefined diff --git a/vendor/cordis/src/logger.ts b/vendor/cordis/src/logger.ts index ad266817bb..c905bce865 100644 --- a/vendor/cordis/src/logger.ts +++ b/vendor/cordis/src/logger.ts @@ -1,4 +1,4 @@ -import { defineProperty, hyphenate } from 'cosmokit' +import { defineProperty, hyphenate } from '@deepseek-ai/cosmokit' import { Context } from './context.ts' import { Fiber } from './fiber.ts' import { createCallable, joinPrototype, symbols, type Tracker } from './utils.ts' diff --git a/vendor/cordis/src/reflect.ts b/vendor/cordis/src/reflect.ts index 63dd5b0cd2..7bbe1ae991 100644 --- a/vendor/cordis/src/reflect.ts +++ b/vendor/cordis/src/reflect.ts @@ -1,5 +1,5 @@ -import { defineProperty, isNullable } from 'cosmokit' -import type { Dict } from 'cosmokit' +import { defineProperty, isNullable } from '@deepseek-ai/cosmokit' +import type { Dict } from '@deepseek-ai/cosmokit' import { Context } from './context.ts' import { getTraceable, symbols, withProps } from './utils.ts' import { Fiber, FiberState } from './fiber.ts' diff --git a/vendor/cordis/src/registry.ts b/vendor/cordis/src/registry.ts index d013e86081..478cf036cb 100644 --- a/vendor/cordis/src/registry.ts +++ b/vendor/cordis/src/registry.ts @@ -1,5 +1,5 @@ -import { defineProperty } from 'cosmokit' -import type { Dict } from 'cosmokit' +import { defineProperty } from '@deepseek-ai/cosmokit' +import type { Dict } from '@deepseek-ai/cosmokit' import type { StandardSchemaV1 } from '@standard-schema/spec' import { Context } from './context.ts' import { Fiber } from './fiber.ts' diff --git a/vendor/cordis/src/service.ts b/vendor/cordis/src/service.ts index f58240368d..dc5742f8c2 100644 --- a/vendor/cordis/src/service.ts +++ b/vendor/cordis/src/service.ts @@ -1,4 +1,4 @@ -import { defineProperty } from 'cosmokit' +import { defineProperty } from '@deepseek-ai/cosmokit' import { Context } from './context.ts' import { createCallable, joinPrototype, symbols, type Tracker } from './utils.ts' diff --git a/vendor/cordis/src/utils.ts b/vendor/cordis/src/utils.ts index 2fd499bd0c..024d21f5e0 100644 --- a/vendor/cordis/src/utils.ts +++ b/vendor/cordis/src/utils.ts @@ -1,4 +1,4 @@ -import { defineProperty } from 'cosmokit' +import { defineProperty } from '@deepseek-ai/cosmokit' import type { Context, Service } from './index.ts' /** Ordered collection of disposable values with O(1) deletion by value. */ diff --git a/vendor/cosmokit/package.json b/vendor/cosmokit/package.json index 940fcdb539..0016ec0bb4 100644 --- a/vendor/cosmokit/package.json +++ b/vendor/cosmokit/package.json @@ -1,8 +1,15 @@ { - "name": "cosmokit", + "name": "@deepseek-ai/cosmokit", "description": "A collection of common utilities", - "version": "1.8.1", - "private": true, + "version": "1.8.2-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "vendor/cosmokit" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", diff --git a/vendor/group/package.json b/vendor/group/package.json index cefb9288fa..03baae1394 100644 --- a/vendor/group/package.json +++ b/vendor/group/package.json @@ -1,8 +1,15 @@ { - "name": "@cordisjs/plugin-group", + "name": "@deepseek-ai/cordis-plugin-group", "description": "Nested plugin group for cordis", - "version": "1.0.0", - "private": true, + "version": "1.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "vendor/group" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -23,7 +30,7 @@ "author": "Shigma ", "license": "MIT", "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/vendor/group/src/index.ts b/vendor/group/src/index.ts index 7654ba9691..cf27374159 100644 --- a/vendor/group/src/index.ts +++ b/vendor/group/src/index.ts @@ -1,3 +1,3 @@ -import { Group } from '@cordisjs/plugin-loader' +import { Group } from '@deepseek-ai/cordis-plugin-loader' export default Group diff --git a/vendor/hmr/package.json b/vendor/hmr/package.json index 0b498fc90c..e231701881 100644 --- a/vendor/hmr/package.json +++ b/vendor/hmr/package.json @@ -1,8 +1,15 @@ { - "name": "@cordisjs/plugin-hmr", + "name": "@deepseek-ai/cordis-plugin-hmr", "description": "Hot Module Replacement Plugin for Cordis", - "version": "1.0.15", - "private": true, + "version": "1.0.16-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "vendor/hmr" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -22,7 +29,7 @@ ], "author": "Shigma ", "license": "MIT", - "cordis": { + "@deepseek-ai/cordis": { "services": { "required": [ "timer" @@ -34,15 +41,15 @@ } }, "peerDependencies": { - "@cordisjs/plugin-timer": "^1.1.2", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-timer": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@babel/code-frame": "^7.29.0", "chokidar": "^4.0.3", - "cosmokit": "^1.8.1", + "@deepseek-ai/cosmokit": "workspace:^", "picomatch": "^4.0.3", - "schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@types/babel__code-frame": "^7.27.0", diff --git a/vendor/hmr/src/error.ts b/vendor/hmr/src/error.ts index 80045c1765..05e9984278 100644 --- a/vendor/hmr/src/error.ts +++ b/vendor/hmr/src/error.ts @@ -1,4 +1,4 @@ -import { Context } from 'cordis' +import { Context } from '@deepseek-ai/cordis' import type { BuildFailure } from 'esbuild' import { codeFrameColumns } from '@babel/code-frame' import { readFileSync } from 'node:fs' diff --git a/vendor/hmr/src/index.ts b/vendor/hmr/src/index.ts index 169ee2e00a..f79d8344dc 100644 --- a/vendor/hmr/src/index.ts +++ b/vendor/hmr/src/index.ts @@ -1,18 +1,18 @@ -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 { Context, Service, type Plugin } from '@deepseek-ai/cordis' +import type { Dict } from '@deepseek-ai/cosmokit' +import { ModuleLoader, type ModuleJob, type ResolveResult } from '@deepseek-ai/cordis-plugin-loader' +import type { Include } from '@deepseek-ai/cordis-plugin-include' import { FSWatcher, watch, type ChokidarOptions } from 'chokidar' import { dirname, relative, resolve } from 'node:path' import { realpath, stat } from 'node:fs/promises' import { handleError } from './error.ts' -import type {} from '@cordisjs/plugin-timer' +import type {} from '@deepseek-ai/cordis-plugin-timer' import { fileURLToPath, pathToFileURL } from 'node:url' import { createRequire } from 'node:module' import picomatch from 'picomatch' -import z from 'schemastery' +import z from '@deepseek-ai/schemastery' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context { hmr: Hmr } @@ -502,7 +502,7 @@ class Hmr extends Service { const reload = (plugin: any, runtime: Plugin.Runtime) => { if (!runtime) return for (const oldFiber of runtime.fibers) { - const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber.config, this.getOuterStack) + const fiber = oldFiber.parent.registry.plugin(plugin, oldFiber._config, this.getOuterStack) fiber.entry = oldFiber.entry if (fiber.entry) fiber.entry.fiber = fiber } diff --git a/vendor/include/package.json b/vendor/include/package.json index c588a33d80..1105e13404 100644 --- a/vendor/include/package.json +++ b/vendor/include/package.json @@ -1,8 +1,15 @@ { - "name": "@cordisjs/plugin-include", + "name": "@deepseek-ai/cordis-plugin-include", "description": "Include files in cordis configurations", - "version": "1.0.4", - "private": true, + "version": "1.0.5-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "vendor/include" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -23,11 +30,11 @@ "author": "Shigma ", "license": "MIT", "peerDependencies": { - "@cordisjs/plugin-loader": "^1.0.0-rc.5", - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "cosmokit": "^1.8.1", + "@deepseek-ai/cosmokit": "workspace:^", "js-yaml": "^4.1.0" } } diff --git a/vendor/include/src/index.ts b/vendor/include/src/index.ts index 59d34c24a3..c67b591978 100644 --- a/vendor/include/src/index.ts +++ b/vendor/include/src/index.ts @@ -1,5 +1,5 @@ -import { EntryTree, isJsExpr, type EntryOptions } from '@cordisjs/plugin-loader' -import { Context, Service } from 'cordis' +import { EntryConfigResolver, EntryTree, interpolate, isJsExpr, type EntryOptions } from '@deepseek-ai/cordis-plugin-loader' +import { Context, Service } from '@deepseek-ai/cordis' import { extname } from 'node:path' import { access, constants, readFile, rename, writeFile } from 'node:fs/promises' import { setTimeout as delay } from 'node:timers/promises' @@ -174,6 +174,21 @@ export namespace Include { export class Include extends EntryTree { static inject = ['loader'] + /** + * Resolve Include's own options while preserving nested entry expressions. + * @param ctx - the Include plugin context. + * @param config - the raw Include config. + * @returns resolved Include options with `initial` and `patches` untouched. + */ + static [EntryConfigResolver](ctx: Context, config: Include.Config): Include.Config { + const { initial, patches, ...own } = config + return { + ...interpolate(ctx, own), + ...(initial === undefined ? {} : { initial }), + ...(patches === undefined ? {} : { patches }), + } + } + public filename: string private type?: string private readonly: boolean diff --git a/vendor/loader/package.json b/vendor/loader/package.json index 509c558ed3..a40bebc668 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -1,8 +1,15 @@ { - "name": "@cordisjs/plugin-loader", + "name": "@deepseek-ai/cordis-plugin-loader", "description": "Plugin loader for cordis", - "version": "1.0.0-rc.5", - "private": true, + "version": "1.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "vendor/loader" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -11,16 +18,11 @@ "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" @@ -28,7 +30,7 @@ "author": "Shigma ", "license": "MIT", "peerDependencies": { - "cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "node-addon-require-builtin": "^0.1.4" }, "peerDependenciesMeta": { @@ -37,7 +39,6 @@ } }, "dependencies": { - "cosmokit": "^1.8.1", - "pnpm": "11.7.0" + "@deepseek-ai/cosmokit": "workspace:^" } } diff --git a/vendor/loader/src/config/entry.ts b/vendor/loader/src/config/entry.ts index d479fa6c0f..3fc74177f9 100644 --- a/vendor/loader/src/config/entry.ts +++ b/vendor/loader/src/config/entry.ts @@ -1,9 +1,20 @@ -import { Context, Fiber, Inject } from 'cordis' -import { deepEqual, isNullable } from 'cosmokit' +import { Context, Fiber, Inject } from '@deepseek-ai/cordis' +import { deepEqual, isNullable } from '@deepseek-ai/cosmokit' import { Loader } from '../index.ts' import { EntryGroup } from './group.ts' import { EntryTree } from './tree.ts' -import { evaluate, interpolate } from './utils.ts' +import { evaluate } from './utils.ts' + +/** Static plugin hook for resolving a container config while preserving nested entry configs. */ +export const EntryConfigResolver = Symbol.for('cordis.loader.entry-config-resolver') + +/** + * Resolve a container's own config while preserving any nested entry configs. + * @param ctx - the container plugin context. + * @param config - the container's raw config. + * @returns the config to validate for this activation. + */ +export type EntryConfigResolver = (ctx: Context, config: any) => any /** Serialized plugin entry options stored in loader config files. */ export interface EntryOptions { @@ -62,6 +73,8 @@ export class Entry { _initTask?: Promise _disposing = 0 + private runtimeEnabled = false + private runtimeEnableTask?: Promise constructor(public loader: Loader) { this.ctx = loader.ctx.extend({ [Entry.key]: this }) @@ -88,22 +101,33 @@ export class Entry { private _disabled(options: EntryOptions) { // group is always enabled if (options.group) return false - if (options.disabled) return true + if (options.disabled && !this.runtimeEnabled) return true let entry = this.parent.ctx.fiber.entry while (entry) { - if (entry.options.disabled) return true + if (entry.options.disabled && !entry.runtimeEnabled) return true entry = entry.parent.ctx.fiber.entry } return false } - evaluate(expr: string) { - return evaluate(this.ctx, expr) + /** + * Enable this in-memory entry without rewriting its configured `disabled` + * value; the override survives config reapplication for this entry object. + * @returns a promise settling after its initial activation attempt. + */ + enableRuntime(): Promise { + if (this.runtimeEnableTask !== undefined) return this.runtimeEnableTask + this.runtimeEnabled = true + this.runtimeEnableTask = this.refresh().catch((error: unknown) => { + this.runtimeEnabled = false + this.runtimeEnableTask = undefined + throw error + }) + return this.runtimeEnableTask } - _resolveConfig(plugin: any): [any, any?] { - if (plugin[EntryGroup.key]) return this.options.config - return interpolate(this.ctx, this.options.config) + evaluate(expr: string) { + return evaluate(this.ctx, expr) } private async _patchContext(diff: string[]) { @@ -111,7 +135,7 @@ export class Entry { Object.setPrototypeOf(this.ctx, this.parent.ctx) if (this.fiber?.uid && (diff.includes('config') || this.options.group)) { - await this.fiber.update(this._resolveConfig(this.fiber.runtime!.callback), true) + await this.fiber.update(this.options.config, true) } }) } @@ -258,7 +282,15 @@ export class Entry { this._initTask = undefined if (!this.loader.getTasks().length) this.ctx.reflect.notify(['loader']) } - await this.fiber?.await() + await this._await() + } + + async _await() { + try { + await this.fiber?.await() + } catch (error) { + throw updateError('apply', this.options, error) + } } private async _init() { @@ -278,17 +310,13 @@ export class Entry { private async _start(plugin: any) { let fiber: Fiber | undefined try { - fiber = await this._create(plugin) + await this._patchContext([]) + this.loader.showLog(this, 'apply') + fiber = this.fiber = this.ctx.registry.plugin(plugin, this.options.config, this.getOuterStack) 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') - 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 8b96187275..2e322b1e26 100644 --- a/vendor/loader/src/config/group.ts +++ b/vendor/loader/src/config/group.ts @@ -1,4 +1,4 @@ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' import { Entry, type EntryOptions } from './entry.ts' import { EntryTree } from './tree.ts' @@ -69,6 +69,10 @@ export class EntryGroup { try { const outcomes = await Promise.allSettled(config.map(options => this.create(options))) + // Disposal owns termination: sibling starts can still be settling after + // the containing tree has gone away, but their failures no longer + // describe a live update to roll back. + if (this.ctx.fiber.uid === null) return const failures = outcomes .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') .map(outcome => outcome.reason) diff --git a/vendor/loader/src/config/isolate.ts b/vendor/loader/src/config/isolate.ts index 9142f3fda5..0dd2a06c92 100644 --- a/vendor/loader/src/config/isolate.ts +++ b/vendor/loader/src/config/isolate.ts @@ -1,5 +1,5 @@ -import { Context } from 'cordis' -import type { Dict } from 'cosmokit' +import { Context } from '@deepseek-ai/cordis' +import type { Dict } from '@deepseek-ai/cosmokit' import { Entry } from './entry.ts' declare module './entry.ts' { diff --git a/vendor/loader/src/config/tree.ts b/vendor/loader/src/config/tree.ts index 8cb9fb984d..c1925f0b90 100644 --- a/vendor/loader/src/config/tree.ts +++ b/vendor/loader/src/config/tree.ts @@ -1,5 +1,5 @@ -import { composeError, Context } from 'cordis' -import { isNonNullable, type Dict } from 'cosmokit' +import { composeError, Context } from '@deepseek-ai/cordis' +import { isNonNullable, type Dict } from '@deepseek-ai/cosmokit' import { Entry, type EntryOptions } from './entry.ts' import { EntryGroup } from './group.ts' @@ -51,7 +51,7 @@ export abstract class EntryTree { continue } const outcomes = await Promise.allSettled( - [...this.entries()].map(entry => entry.fiber?.await()), + [...this.entries()].map(entry => entry._await()), ) const failures = outcomes .filter((outcome): outcome is PromiseRejectedResult => outcome.status === 'rejected') diff --git a/vendor/loader/src/config/utils.ts b/vendor/loader/src/config/utils.ts index 4e193fcc4f..cd706c0407 100644 --- a/vendor/loader/src/config/utils.ts +++ b/vendor/loader/src/config/utils.ts @@ -1,4 +1,4 @@ -import { valueMap } from 'cosmokit' +import { valueMap } from '@deepseek-ai/cosmokit' // eslint-disable-next-line no-new-func /** Evaluate a JavaScript expression against a loader context scope. */ diff --git a/vendor/loader/src/index.ts b/vendor/loader/src/index.ts index 798354c7b0..3fe3e57949 100644 --- a/vendor/loader/src/index.ts +++ b/vendor/loader/src/index.ts @@ -1,9 +1,16 @@ -import { Context, Inject, Service } from 'cordis' -import { defineProperty, isNullable, type Dict } from 'cosmokit' +import { Context, FiberState, Inject, Service, type Fiber } from '@deepseek-ai/cordis' +import { defineProperty, isNullable, type Dict } from '@deepseek-ai/cosmokit' import { ModuleLoader } from './internal.ts' -import { Entry, type EntryOptions } from './config/entry.ts' +import { + Entry, + EntryConfigResolver, + type EntryConfigResolver as ConfigResolver, + type EntryOptions, +} from './config/entry.ts' +import { EntryGroup } from './config/group.ts' import isolate from './config/isolate.ts' import { EntryTree } from './config/tree.ts' +import { interpolate } from './config/utils.ts' /** Re-export entry node APIs. */ export * from './config/entry.ts' @@ -18,7 +25,7 @@ export * from './config/utils.ts' /** Re-export Node internal module loader compatibility types. */ export * from './internal.ts' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Events { 'exit'(signal: NodeJS.Signals): Promise 'loader/config-update'(): void @@ -87,6 +94,15 @@ export class Loader extends EntryTree { ctx.reflect.provide('loader', this, this[Service.check]) + ctx.on('internal/config', function (this: Fiber, _config, next) { + const config = next() + if (!this.entry || this.parent.fiber?.entry === this.entry) return config + const plugin = this.runtime?.callback as Record | undefined + if (plugin?.[EntryGroup.key]) return config + const resolve = plugin?.[EntryConfigResolver] as ConfigResolver | undefined + return resolve ? resolve(this.ctx, config) : interpolate(this.ctx, config) + }, { global: true }) + ctx.on('internal/update', async function (config, noSave, next) { if (!this.entry || noSave || this.parent.fiber?.entry === this.entry) return next() await next() @@ -127,7 +143,8 @@ export class Loader extends EntryTree { if (!ctx.registry.has(fiber.runtime!.callback)) return // case 5: the entry's tree is being disposed - if (!fiber.entry.parent.tree.ctx.fiber.uid) return + const treeOwner = fiber.entry.parent.tree.ctx.fiber + if (!treeOwner.uid || treeOwner.state === FiberState.UNLOADING) return // case 6: Loader is replacing or removing this exact fiber if (fiber.entry._disposing) return diff --git a/vendor/loader/src/internal.ts b/vendor/loader/src/internal.ts index 38d6f589f5..ccf08debc6 100644 --- a/vendor/loader/src/internal.ts +++ b/vendor/loader/src/internal.ts @@ -1,5 +1,5 @@ import { createRequire, type LoadHookContext } from 'node:module' -import type { Dict } from 'cosmokit' +import type { Dict } from '@deepseek-ai/cosmokit' /** Node internal module format names handled by loader hooks. */ export type ModuleFormat = 'builtin' | 'commonjs' | 'json' | 'module' | 'wasm' diff --git a/vendor/loader/src/repository.ts b/vendor/loader/src/repository.ts deleted file mode 100644 index c85b84ccfc..0000000000 --- a/vendor/loader/src/repository.ts +++ /dev/null @@ -1,258 +0,0 @@ -/** - * 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 { tmpdir } from 'node:os' -import { delimiter, 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 - -/** Installation controls for {@link RepositoryCache}. */ -export interface RepositoryCacheOptions { - /** Override the isolated package installation boundary. */ - install?: RepositoryInstall -} - -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 normalizedEnvironmentPath(value: string): string { - const unquoted = value.startsWith('"') && value.endsWith('"') ? value.slice(1, -1) : value - const normalized = resolve(unquoted) - return process.platform === 'win32' ? normalized.toUpperCase() : normalized -} - -function installEnvironment(commandDirectory: string): NodeJS.ProcessEnv { - const scrubbed = scrubEnvironment() - const path = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATH')?.[1] - const pathExt = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PATHEXT')?.[1] - const pnpmHome = Object.entries(scrubbed).find(([name]) => name.toUpperCase() === 'PNPM_HOME')?.[1] - const normalizedPnpmHome = pnpmHome === undefined ? undefined : normalizedEnvironmentPath(pnpmHome) - const inheritedPath = path === undefined ? [] : path.split(delimiter).filter((entry) => { - return normalizedPnpmHome === undefined || normalizedEnvironmentPath(entry) !== normalizedPnpmHome - }) - const pathExtensions = pathExt?.split(';') - const prioritizedPathExt = pathExtensions === undefined ? undefined : [ - ...pathExtensions.filter(extension => extension.toUpperCase() === '.CMD'), - ...pathExtensions.filter(extension => extension.toUpperCase() !== '.CMD'), - ].join(';') - const withoutOverrides = Object.fromEntries(Object.entries(scrubbed).filter(([name]) => { - return !['PATH', 'PATHEXT', 'PNPM_CONFIG_IGNORE_WORKSPACE'].includes(name.toUpperCase()) - })) - return { - ...withoutOverrides, - PATH: [commandDirectory, ...inheritedPath].join(delimiter), - // cmd.exe tests PATHEXT before later PATH entries, so the transaction's - // pnpm.cmd must precede an inherited pnpm executable from PNPM_HOME. - ...(prioritizedPathExt === undefined ? {} : { PATHEXT: prioritizedPathExt }), - PNPM_CONFIG_IGNORE_WORKSPACE: 'true', - } -} - -function shellQuote(value: string): string { - return `'${value.replaceAll("'", "'\\''")}'` -} - -function batchQuote(value: string): string { - return `"${value.replaceAll('%', '%%')}"` -} - -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') - const commandDirectory = await mkdtemp(join(tmpdir(), 'cordis-repository-pnpm-')) - try { - await Promise.all([ - writeFile(join(commandDirectory, 'pnpm'), [ - '#!/bin/sh', - `exec ${shellQuote(process.execPath)} ${shellQuote(pnpmBin)} --ignore-workspace "$@"`, - '', - ].join('\n'), { mode: 0o700 }), - writeFile(join(commandDirectory, 'pnpm.cmd'), [ - '@echo off', - `${batchQuote(process.execPath)} ${batchQuote(pnpmBin)} --ignore-workspace %*`, - '', - ].join('\r\n'), { mode: 0o700 }), - ]) - 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: installEnvironment(commandDirectory), - 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()}` : ''}`) - } - } finally { - await rm(commandDirectory, { recursive: true, force: true }) - } -} - -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>() - private readonly install: RepositoryInstall - - /** - * @param directory - caller-owned persistent cache root. - * @param options - isolated installer override. - */ - constructor(directory: string, options: RepositoryCacheOptions = {}) { - this.directory = resolve(directory) - this.install = options.install ?? installWithBundledPnpm - } - - /** - * 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 index 75e627cdd2..b1e43952f2 100644 --- a/vendor/loader/tsdown.config.ts +++ b/vendor/loader/tsdown.config.ts @@ -1,6 +1,5 @@ import { defineConfig } from 'tsdown' -/** Keep the browser-reachable Loader entry separate from the Node-only repository cache. */ const shared = { outDir: 'lib', format: ['esm'], @@ -14,5 +13,4 @@ const shared = { export default defineConfig([ { ...shared, entry: ['lib/types/index.js'] }, - { ...shared, entry: ['lib/types/repository.js'] }, ]) diff --git a/vendor/logger-console/package.json b/vendor/logger-console/package.json index 7af021c45a..ceb1c0bd75 100644 --- a/vendor/logger-console/package.json +++ b/vendor/logger-console/package.json @@ -1,8 +1,15 @@ { - "name": "@cordisjs/plugin-logger-console", + "name": "@deepseek-ai/cordis-plugin-logger-console", "description": "Console logger exporter for cordis", - "version": "1.0.0", - "private": true, + "version": "1.0.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "vendor/logger-console" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/shared.d.ts", @@ -25,11 +32,11 @@ "author": "Shigma ", "license": "MIT", "peerDependencies": { - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "cosmokit": "^1.8.1", - "schemastery": "^3.18.0", + "@deepseek-ai/cosmokit": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "supports-color": "^9.4.0" } } diff --git a/vendor/logger-console/src/browser.ts b/vendor/logger-console/src/browser.ts index b45a15e228..e77ec3c3fb 100644 --- a/vendor/logger-console/src/browser.ts +++ b/vendor/logger-console/src/browser.ts @@ -1,4 +1,4 @@ -import { Message } from 'cordis' +import { Message } from '@deepseek-ai/cordis' import { ConsoleExporter as Base } from './shared.ts' /** Re-export shared console exporter config and base implementation. */ diff --git a/vendor/logger-console/src/index.ts b/vendor/logger-console/src/index.ts index d46ac6413f..3ed272a9ec 100644 --- a/vendor/logger-console/src/index.ts +++ b/vendor/logger-console/src/index.ts @@ -1,4 +1,4 @@ -import { Formatter } from 'cordis' +import { Formatter } from '@deepseek-ai/cordis' import { inspect } from 'node:util' import supportsColor from 'supports-color' import { ConsoleExporter as Base } from './shared.ts' diff --git a/vendor/logger-console/src/shared.ts b/vendor/logger-console/src/shared.ts index 61d91abcb3..942bc54746 100644 --- a/vendor/logger-console/src/shared.ts +++ b/vendor/logger-console/src/shared.ts @@ -1,6 +1,6 @@ -import { Context, Exporter, Formatter, Logger, Message } from 'cordis' -import { Time } from 'cosmokit' -import z from 'schemastery' +import { Context, Exporter, Formatter, Logger, Message } from '@deepseek-ai/cordis' +import { Time } from '@deepseek-ai/cosmokit' +import z from '@deepseek-ai/schemastery' /** Terminal color support level compatible with supports-color. */ export type ColorSupportLevel = 0 | 1 | 2 | 3 diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index f23fac56db..76f3748a5b 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -1,8 +1,15 @@ { - "name": "schemastery", + "name": "@deepseek-ai/schemastery", "description": "Type driven schema validator", - "version": "3.18.0", - "private": true, + "version": "3.18.1-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "vendor/schemastery" + }, "type": "module", "main": "lib/index.cjs", "module": "lib/index.mjs", @@ -27,6 +34,6 @@ "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", - "cosmokit": "^1.8.1" + "@deepseek-ai/cosmokit": "workspace:^" } } diff --git a/vendor/schemastery/src/index.ts b/vendor/schemastery/src/index.ts index 5948797ae9..56a499e1cc 100644 --- a/vendor/schemastery/src/index.ts +++ b/vendor/schemastery/src/index.ts @@ -1,4 +1,4 @@ -import { Binary, clone, deepEqual, filterKeys, isNullable, isPlainObject, pick, valueMap, type Dict } from 'cosmokit' +import { Binary, clone, deepEqual, filterKeys, isNullable, isPlainObject, pick, valueMap, type Dict } from '@deepseek-ai/cosmokit' import type { StandardSchemaV1 } from '@standard-schema/spec' const kSchema = Symbol.for('schemastery') diff --git a/vendor/timer/package.json b/vendor/timer/package.json index 4ae59cd0bd..211dba0036 100644 --- a/vendor/timer/package.json +++ b/vendor/timer/package.json @@ -1,8 +1,15 @@ { - "name": "@cordisjs/plugin-timer", + "name": "@deepseek-ai/cordis-plugin-timer", "description": "Timer service for cordis", - "version": "1.1.2", - "private": true, + "version": "1.1.3-rc.1", + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "vendor/timer" + }, "type": "module", "main": "lib/index.js", "types": "lib/types/index.d.ts", @@ -23,9 +30,9 @@ "author": "Shigma ", "license": "MIT", "peerDependencies": { - "cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "cosmokit": "^1.8.1" + "@deepseek-ai/cosmokit": "workspace:^" } } diff --git a/vendor/timer/src/index.ts b/vendor/timer/src/index.ts index 1a33850aa3..009d14a5d4 100644 --- a/vendor/timer/src/index.ts +++ b/vendor/timer/src/index.ts @@ -1,6 +1,6 @@ -import { Context, Service } from 'cordis' +import { Context, Service } from '@deepseek-ai/cordis' -declare module 'cordis' { +declare module '@deepseek-ai/cordis' { interface Context extends Pick { timer: TimerService } diff --git a/website/.vitepress/config.ts b/website/.vitepress/config.ts index 4dc1a5774b..4cef3f0a67 100644 --- a/website/.vitepress/config.ts +++ b/website/.vitepress/config.ts @@ -94,14 +94,14 @@ const sharedTheme: Pick { const data: unknown = frontmatter const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') - return `https://github.com/deepseek-ai/deepseek-harness-sdk/edit/master/${editSource}` + return `https://github.com/deepseek-ai/deepseek-harness/edit/master/${editSource}` }, text: '在 GitHub 上编辑此页', }, @@ -161,7 +161,7 @@ export default withMermaid({ const data: unknown = frontmatter const editSource: unknown = typeof data === 'object' && data !== null ? Reflect.get(data, 'editSource') : undefined if (typeof editSource !== 'string') throw new Error('Projected documentation page has no editSource frontmatter.') - return `https://github.com/deepseek-ai/deepseek-harness-sdk/edit/master/${editSource}` + return `https://github.com/deepseek-ai/deepseek-harness/edit/master/${editSource}` }, text: 'Edit this page on GitHub', }, diff --git a/website/docs.ts b/website/docs.ts index 9fcdc7c1a7..0019fd3a28 100644 --- a/website/docs.ts +++ b/website/docs.ts @@ -138,13 +138,21 @@ const homeAndGuide = pairedPages([ section: { root: '入门', en: 'Guide' }, order: 3, }, + { + source: 'docs/user/guide/python-sdk.md', + route: 'guide/python-sdk.md', + label: { root: 'Python SDK', en: 'Python SDK' }, + sidebar: { root: 'zh-guide', en: 'en-guide' }, + section: { root: '入门', en: 'Guide' }, + order: 4, + }, { source: 'docs/user/guide/config.md', route: 'guide/config.md', label: { root: '配置文件', en: 'Configuration' }, sidebar: { root: 'zh-guide', en: 'en-guide' }, section: { root: '入门', en: 'Guide' }, - order: 4, + order: 5, }, ])