From c44c6c93c81a9007d29e64820af2981fd728b48f Mon Sep 17 00:00:00 2001 From: NI0317 Date: Fri, 7 Aug 2026 14:33:18 +0800 Subject: [PATCH 001/113] feat(web): open workspace picker from composer --- ...ession-scope-and-provide-channel.i18n.yaml | 4 +-- ...lient-session-scope-and-provide-channel.md | 2 +- ...nt-session-scope-and-provide-channel.zh.md | 2 +- ...-workspace-picker-composer-entry.i18n.yaml | 6 ++++ ...6-08-07-workspace-picker-composer-entry.md | 29 +++++++++++++++ ...8-07-workspace-picker-composer-entry.zh.md | 29 +++++++++++++++ apps/web/tests/support.ts | 8 ++--- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 4 +-- packages/client/ui-conversation/README.zh.md | 4 +-- .../src/client/contract/slots.ts | 14 +++++--- .../src/client/skeleton/ConversationRoot.tsx | 9 +++-- .../src/client/skeleton/InputBar.module.css | 4 +++ .../src/client/skeleton/InputBar.tsx | 35 ++++++++++++++----- .../tests/assembly-surfaces.spec.tsx | 18 +++++++--- .../ui-conversation/tests/input-bar.spec.tsx | 26 ++++++++++++++ 16 files changed, 165 insertions(+), 33 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.md create mode 100644 .agents/notes/implemented/feature/2026-08-07-workspace-picker-composer-entry.zh.md 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 9c8308be74..403453b3f7 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: 3c51f06fca23a495f0fbc0cc4f1c289edea07b3b -2026-07-25-web-client-session-scope-and-provide-channel.zh.md: 06fc1005785d9d11b52839f91c3bb4b99cad7d63 +2026-07-25-web-client-session-scope-and-provide-channel.md: 42eaf47f08d0b1fe5abf4389accfdbae1097a529 +2026-07-25-web-client-session-scope-and-provide-channel.zh.md: c55decd8b92bbc954811cc08df3a6b28a5662258 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 3c51f06fca..42eaf47f08 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 @@ -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 textarea 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, awaiting its own branch). 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 06fc100578..c55decd8b9 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 @@ -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;这段时间内,常驻 textarea 会把激活操作转到 Workspace 选择器([决策](../feature/2026-08-07-workspace-picker-composer-entry.md))。 - 已知欠账:approval/question 跨 prune 恢复(TODO);模型选择以 live-mutation 形状回归(host `selectModel` 三件套现成,等独立分支)。 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..b3b26481c9 --- /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: 42c24f86eac554ffd642199d6fe15b5d0513db28 +2026-08-07-workspace-picker-composer-entry.zh.md: 4ff38599df122ef8b866798aef3d43b7c848ea18 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..42c24f86ea --- /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 resident textarea is read-only and activates the existing `conversation.hero.workspace` picker by pointer click, Enter, or Space. It exposes menu expansion state through `aria-haspopup` and `aria-expanded`. 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, 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..4ff38599df --- /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 时,常驻 textarea 为只读状态,并可通过鼠标点击、Enter 或 Space 激活现有的 `conversation.hero.workspace` 选择器。它通过 `aria-haspopup` 和 `aria-expanded` 暴露菜单展开状态。消息提交、命令、权限、模型及其他 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 状态、传输或目录选择流程。 + +组件测试会固定鼠标和键盘激活、相邻控件锁定、选择器展开,以及同一节点变为可编辑 textarea 的过渡。组装后的 Web helper 会通过 textarea 开始全新 Workspace 设置,因此重放浏览器场景会覆盖实际交付路径。 diff --git a/apps/web/tests/support.ts b/apps/web/tests/support.ts index 1b7b67aab3..36b65db8b6 100644 --- a/apps/web/tests/support.ts +++ b/apps/web/tests/support.ts @@ -58,9 +58,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 @@ -74,7 +74,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() @@ -98,7 +98,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/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index f5ad20c3fc..873a6977f7 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: a75f25d8669cd688795842a655106e0e27bb7173 -README.zh.md: f0d744c31020730857d210d75749b907dbffca08 +README.md: 73e638ddde4ad7be73a50cfba7e19cbe09a548b8 +README.zh.md: 80e9fd7dd82a68d0223be61ecbcdce18214a6fdd diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index a75f25d866..73e638ddde 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. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. -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 keeps message actions locked and presents the resident textarea as a read-only Workspace-picker trigger; its root-scoped `conversation.hero.workspace` slot hosts the existing 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 view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: `), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. @@ -44,7 +44,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 button), 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 button), 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 read-only textarea opens the existing Workspace picker by click, Enter, or Space. It does not swap in a parallel tree, so the textarea DOM survives Workspace selection; the strict-session control seats simply 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 moved off the row onto 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 f0d744c310..80e9fd7dd8 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`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会渲染禁用输入栏;其根作用域的 `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 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让常驻 textarea 成为只读的 Workspace 选择器入口;其根作用域的 `conversation.hero.workspace` slot 承载现有选择器。选择 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 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: `);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 @@ -44,7 +44,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),只读 textarea 则可通过点击、Enter 或 Space 打开现有 Workspace 选择器。它不会换入一棵平行树,因此选择 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/src/client/contract/slots.ts b/packages/client/ui-conversation/src/client/contract/slots.ts index a84b4a3bf0..64106ad85f 100644 --- a/packages/client/ui-conversation/src/client/contract/slots.ts +++ b/packages/client/ui-conversation/src/client/contract/slots.ts @@ -83,8 +83,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. @@ -259,11 +260,14 @@ export interface ComposerBarOwnerProps { /** Hero = empty-state centered card; composer = resident bottom bar. */ variant: 'hero' | 'composer' /** - * 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 Workspace picker opened from this bar is currently expanded. */ + 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/skeleton/ConversationRoot.tsx b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx index bc5f7bdc01..18873a47e0 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.tsx @@ -120,7 +120,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, @@ -129,7 +129,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) }, + } : hero ? { placeholder: t('placeholder.hero') } : {}), overlay: renderSlot('conversation.input.overlay', {}), leftItems: zone === undefined ? null : renderSlot('conversation.input.left', zone), 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 6ab387c3eb..ca5aab43e5 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css @@ -243,6 +243,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 131f63c49d..a90b37c449 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -37,7 +37,9 @@ export type InputBarProps = ComposerBarProps export function InputBar({ useSession, useInput, inputActions, keyboard, resolveSubmitMode, toggleCommandMenu, stop, command, t, renderSlot, useNotices, useLexicon, useMenuLauncher, - useProjection, sessionId, variant, disabled: inert = false, placeholder, accessory, overlay, leftItems, rightItems, footer, + useProjection, sessionId, variant, disabled: inert = false, + workspacePickerOpen = false, onRequestWorkspace, + placeholder, accessory, overlay, leftItems, rightItems, footer, }: InputBarProps) { const input = useInput(s => s) const notice = useNotices(s => s) @@ -89,6 +91,12 @@ export function InputBar({ const disabled = removed || inert || !live const locked = disabled 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) // Scroll the draft scrollport the minimum that brings `caret` into view — the // browser's own behavior for typing, performed for the paths where it does @@ -200,8 +208,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. @@ -255,7 +270,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) @@ -281,7 +296,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 @@ -306,7 +321,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 text = e.clipboardData.getData('text/plain') if (text === '') return @@ -469,8 +484,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 ?? (disabled ? t('placeholder.unavailable') @@ -478,6 +496,7 @@ export function InputBar({ rows={2} onChange={onChange} onKeyDown={onKeyDown} + onClick={workspaceTrigger ? onRequestWorkspace : undefined} onSelect={onSelect} onCopy={(e) => { onCopyOrCut(e, false) }} onCut={(e) => { onCopyOrCut(e, true) }} diff --git a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx index 16163065eb..9a0974e6a2 100644 --- a/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx +++ b/packages/client/ui-conversation/tests/assembly-surfaces.spec.tsx @@ -189,12 +189,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() - // No session entity: the inert twin renders (disabled textarea), and the - // workspace picker chip is the only live control. 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() }) @@ -219,7 +227,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) @@ -239,6 +248,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/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index bc276174a3..f42030d2ca 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -55,6 +55,9 @@ interface BenchOptions { running?: boolean subagent?: Exclude disabled?: boolean + inert?: boolean + workspacePickerOpen?: boolean + onRequestWorkspace?: () => void promptError?: ConversationSnapshot['promptError'] variant?: 'hero' | 'composer' placeholder?: string @@ -135,6 +138,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 } : {}), @@ -539,6 +545,26 @@ describe('running and lock semantics (queue cut 1)', () => { 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) + }) + 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('描述你的任务以生成计划') From 0a248bcea84afa5e98b347ad27a81f4d81047edc Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Fri, 7 Aug 2026 17:06:01 +0800 Subject: [PATCH 002/113] feat(web): make the whole no-workspace composer card the picker trigger MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The pick target grows from the read-only textarea to the full capsule: the card owns the click, its disabled controls let pointer events fall through, and a dashed l4 stroke with a business-blue hover marks the affordance (an SVG dash ring — native dashed has a fixed pattern). The card contains pointerdown so the open picker's outside-close cannot race the reopen, which flickered the chip's expansion echo. --- ...-workspace-picker-composer-entry.i18n.yaml | 4 +-- ...6-08-07-workspace-picker-composer-entry.md | 4 +-- ...8-07-workspace-picker-composer-entry.zh.md | 4 +-- .../client/ui-conversation/README.i18n.yaml | 4 +-- packages/client/ui-conversation/README.md | 4 +-- packages/client/ui-conversation/README.zh.md | 4 +-- .../src/client/skeleton/InputBar.module.css | 34 +++++++++++++++++++ .../src/client/skeleton/InputBar.tsx | 13 +++++-- .../ui-conversation/tests/input-bar.spec.tsx | 14 ++++++++ 9 files changed, 71 insertions(+), 14 deletions(-) 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 index b3b26481c9..166af490b6 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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: 42c24f86eac554ffd642199d6fe15b5d0513db28 -2026-08-07-workspace-picker-composer-entry.zh.md: 4ff38599df122ef8b866798aef3d43b7c848ea18 +2026-08-07-workspace-picker-composer-entry.md: f4b619d4fb56c889e765b613fefe9ea24bff7112 +2026-08-07-workspace-picker-composer-entry.zh.md: 6c264ab89f9c7b0378b23343999a861aa8f0714f 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 index 42c24f86ea..f4b619d4fb 100644 --- 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 @@ -10,7 +10,7 @@ The [session-scope decision](../architecture/2026-07-25-web-client-session-scope ## Decision -While no Workspace owns the new Session, the resident textarea is read-only and activates the existing `conversation.hero.workspace` picker by pointer click, Enter, or Space. It exposes menu expansion state through `aria-haspopup` and `aria-expanded`. Message submission, command, permission, model, and other Session-scoped controls remain locked until Workspace selection creates or reconnects a real Session. +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, exposing menu expansion state through `aria-haspopup` and `aria-expanded`. 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. @@ -26,4 +26,4 @@ Workspace selection retains the existing owner and flow. `ConversationRoot` open 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, 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. +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 index 4ff38599df..6c264ab89f 100644 --- 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 @@ -10,7 +10,7 @@ Status: implemented ## 决策 -新会话尚未归属任何 Workspace 时,常驻 textarea 为只读状态,并可通过鼠标点击、Enter 或 Space 激活现有的 `conversation.hero.workspace` 选择器。它通过 `aria-haspopup` 和 `aria-expanded` 暴露菜单展开状态。消息提交、命令、权限、模型及其他 Session 作用域控件会保持锁定,直到用户选择 Workspace 并创建或重新连接真实 Session。 +新会话尚未归属任何 Workspace 时,整张输入卡片都可通过鼠标点击激活现有的 `conversation.hero.workspace` 选择器——点击处理器归卡片所有,其禁用控件放行指针事件,因此整个胶囊是同一个目标;只读的常驻 textarea 也可经 Enter 或 Space 激活,并通过 `aria-haspopup` 和 `aria-expanded` 暴露菜单展开状态。虚线 l4 描边(SVG dash ring,因为原生 `dashed` 的间距不可调)配合 hover 时的 business 蓝,把卡片标记为选择入口。卡片会拦下 `pointerdown`,使已打开选择器的外点关闭无法与点击的重新打开竞态——先关后开会让 chip 的展开回显闪动。消息提交、命令、权限、模型及其他 Session 作用域控件会保持锁定,直到用户选择 Workspace 并创建或重新连接真实 Session。 Workspace 选择继续使用现有 owner 和流程。`ConversationRoot` 打开选择器,`WorkspacePicker` 列出或创建 Workspace;Session 到达后,同一个 textarea DOM 节点变为可编辑状态。 @@ -26,4 +26,4 @@ Workspace 选择继续使用现有 owner 和流程。`ConversationRoot` 打开 用户首次点击编辑器即可继续必要的设置流程,键盘用户也能激活同一路径。textarea 会如实报告只读状态,直到 Session 存在;相邻控件仍处于禁用状态。界面没有引入新的 Workspace 状态、传输或目录选择流程。 -组件测试会固定鼠标和键盘激活、相邻控件锁定、选择器展开,以及同一节点变为可编辑 textarea 的过渡。组装后的 Web helper 会通过 textarea 开始全新 Workspace 设置,因此重放浏览器场景会覆盖实际交付路径。 +组件测试会固定鼠标和键盘激活、覆盖整卡的点击目标、被拦下的 `pointerdown`、相邻控件锁定、选择器展开,以及同一节点变为可编辑 textarea 的过渡。组装后的 Web helper 会通过 textarea 开始全新 Workspace 设置,因此重放浏览器场景会覆盖实际交付路径。 diff --git a/packages/client/ui-conversation/README.i18n.yaml b/packages/client/ui-conversation/README.i18n.yaml index 873a6977f7..0a9f7e1cf7 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: 73e638ddde4ad7be73a50cfba7e19cbe09a548b8 -README.zh.md: 80e9fd7dd82a68d0223be61ecbcdce18214a6fdd +README.md: 899afe5f9bef83ece6d1dd84ae88b5c64b7cc9ad +README.zh.md: c6213eb76249870dfa59ea6b9986a10194b7670e diff --git a/packages/client/ui-conversation/README.md b/packages/client/ui-conversation/README.md index 73e638ddde..899afe5f9b 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. The disclosure renders the checkpoint's `compact/summary` provenance; when that event is outside the loaded window, the row remains visible but non-expandable. The framed checkpoint payload is model-facing and never renders. -The resident conversation shell survives no-session and session transitions. Without a current session it keeps message actions locked and presents the resident textarea as a read-only Workspace-picker trigger; its root-scoped `conversation.hero.workspace` slot hosts the existing 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 keeps message actions locked and presents the whole composer card — dashed-border affordance, resident textarea read-only — as a Workspace-picker trigger; its root-scoped `conversation.hero.workspace` slot hosts the existing 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 view ring is a slot: the strict session-body registration declares the session-scoped `'conversation.view'` list in its `children` table, that body renders the active entry through its renderSlot share (`only: `), and view tabs project from registration options (`id`/`order`/`label`). The chat view is this package's own entry; plugins such as ui-trajectory contribute tabs through `ctx.slots.register`, and each view owns its chrome. @@ -44,7 +44,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 button), 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 read-only textarea opens the existing Workspace picker by click, Enter, or Space. It does not swap in a parallel tree, so the textarea DOM survives Workspace selection; 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 button), 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-border card opens the existing Workspace picker by click (its disabled controls let pointer events fall through, and the card contains `pointerdown` so the open picker's outside-close cannot race the reopen) and the read-only textarea does the same by Enter or Space. It does not swap in a parallel tree, so the textarea DOM survives Workspace selection; the strict-session control seats simply 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 moved off the row onto 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 80e9fd7dd8..c6213eb762 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`;该事件位于已加载窗口之外时,标记仍然可见但不可展开。面向模型的带框检查点载荷绝不渲染。 -常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让常驻 textarea 成为只读的 Workspace 选择器入口;其根作用域的 `conversation.hero.workspace` slot 承载现有选择器。选择 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 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 +常驻会话壳会跨无会话与会话状态切换而保留。没有当前会话时,它会锁定消息操作,并让整张输入卡片——虚线边框示能、常驻 textarea 只读——成为 Workspace 选择器入口;其根作用域的 `conversation.hero.workspace` slot 承载现有选择器。选择 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 上的滚轮会链式处理:限高草稿先在本地滚动,到达边缘后再转交给该宿主。 视图环是一个 slot:严格会话主体注册在 `children` 表中声明 Session scope 的 `'conversation.view'` 列表,并通过自身的 renderSlot share 渲染活跃配置项(`only: `);视图标签页则从注册选项(`id`/`order`/`label`)投影而来。聊天视图是该包自身的配置项;ui-trajectory 等插件通过 `ctx.slots.register` 贡献标签页,每个视图负责自己的 chrome。 @@ -44,7 +44,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),只读 textarea 则可通过点击、Enter 或 Space 打开现有 Workspace 选择器。它不会换入一棵平行树,因此选择 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 选择器(其禁用控件放行指针事件;卡片并拦下 `pointerdown`,使已打开选择器的外点关闭不会与重新打开竞态),只读 textarea 也可经 Enter 或 Space 打开。它不会换入一棵平行树,因此选择 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/src/client/skeleton/InputBar.module.css b/packages/client/ui-conversation/src/client/skeleton/InputBar.module.css index ca5aab43e5..9774e612c9 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); +} + .accessory { display: flex; align-items: center; diff --git a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx index a90b37c449..44570056fb 100644 --- a/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/InputBar.tsx @@ -467,7 +467,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). */} +
{ e.stopPropagation() } : undefined} + > {overlay !== undefined &&
{overlay}
} {accessory !== undefined &&
{accessory}
} {/* One scrollport, two text layers. The hidden mirror renders draft+'\n' and stretches the @@ -496,7 +506,6 @@ export function InputBar({ rows={2} onChange={onChange} onKeyDown={onKeyDown} - onClick={workspaceTrigger ? onRequestWorkspace : undefined} onSelect={onSelect} onCopy={(e) => { onCopyOrCut(e, false) }} onCut={(e) => { onCopyOrCut(e, true) }} diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index f42030d2ca..5d15fcb7b4 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -563,6 +563,20 @@ describe('running and lock semantics (queue cut 1)', () => { 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', () => { From eab0aeb9dbf5c16c996b3aa7b8fc4d6e9a33b76a Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sat, 8 Aug 2026 23:29:41 +0800 Subject: [PATCH 003/113] feat(web): list background tasks in the session header MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The task registry has run every background bash, pwsh, pty-send, and one-shot subagent since it landed, but only the model could read it: a human at the Web client could not see that a build was running, tell a finished task from a stuck one, or find its outcome anywhere but the `run_in_background` tool card that printed an id and never updated. Task state now reaches the browser as one whole-snapshot `session/tasks` mux frame per session, pushed at every registry commit that changes what that session can see. `TaskService` gains `onTasksChanged`, which is owner-granular because owner-disposal removal is a change no per-task record can express. The carrier reads the exact owner the listener hands it, so a push stays correct while that scope tears down, and reads the baseline through the non-resuming `ctx.agents.get` so listing never revives a cold session. The client keeps a last-wins mirror on `SessionListState`, and a new `dsh-client-ui-task` package renders it beside the subagent catalog — rendering nothing at all until the session has a task, so an ordinary conversation grows no new chrome. Streamed per-task output and human-initiated cancellation are separate phases; the note records why neither has to undo this channel, and why no Web path may call the consuming `ctx.tasks.read()`. --- ...8-08-web-background-task-display.i18n.yaml | 6 + .../2026-08-08-web-background-task-display.md | 136 ++++++++++ ...26-08-08-web-background-task-display.zh.md | 136 ++++++++++ apps/web/tests/background-task-list.e2e.ts | 130 ++++++++++ .../background-task-list/running.expected.md | 2 + .../background-task-list/settled.expected.md | 2 + apps/web/tsconfig.json | 1 + docs/config-catalog.md | 1 + docs/cordis-catalog/services.md | 20 +- docs/core-data-structures/tasks.i18n.yaml | 4 +- docs/core-data-structures/tasks.md | 2 +- docs/core-data-structures/tasks.zh.md | 2 +- packages/bundle/web-app/cordis.patch.yml | 4 + packages/bundle/web-app/package.json | 1 + packages/client/README.i18n.yaml | 4 +- packages/client/README.md | 1 + packages/client/README.zh.md | 1 + packages/client/connection/src/client/api.ts | 1 + .../client/connection/src/client/index.ts | 1 + .../client/locale/tests/language-row.spec.tsx | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 2 + packages/client/runtime/README.zh.md | 2 + packages/client/runtime/src/client/index.ts | 2 +- .../runtime/src/client/sessions/manager.ts | 28 +- .../runtime/src/client/sessions/service.ts | 14 +- packages/client/runtime/tests/manager.spec.ts | 57 +++++ packages/client/test-runtime/src/sessions.ts | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 2 +- .../tests/enter-behavior-row.spec.tsx | 2 +- .../tests/gate-branch-tails.spec.tsx | 4 +- .../ui-conversation/tests/input-bar.spec.tsx | 2 +- .../tests/input-matrix.spec.tsx | 2 +- .../tests/input-scenarios.spec.tsx | 2 +- .../ui-conversation/tests/skeleton.spec.tsx | 2 +- .../tests/conversation-ui.spec.tsx | 1 + packages/client/ui-task/README.i18n.yaml | 6 + packages/client/ui-task/README.md | 24 ++ packages/client/ui-task/README.zh.md | 24 ++ packages/client/ui-task/package.json | 68 +++++ .../src/client/TaskListAction.module.css | 116 +++++++++ .../ui-task/src/client/TaskListAction.tsx | 185 ++++++++++++++ packages/client/ui-task/src/client/index.ts | 40 +++ packages/client/ui-task/src/client/locales.ts | 45 ++++ packages/client/ui-task/src/css-modules.d.ts | 6 + packages/client/ui-task/src/index.ts | 9 + packages/client/ui-task/src/invariant.ts | 32 +++ .../ui-task/tests/browser-plugin.spec.ts | 89 +++++++ .../ui-task/tests/task-list-action.spec.tsx | 239 ++++++++++++++++++ packages/client/ui-task/tsconfig.json | 33 +++ packages/client/ui-task/tsdown.config.ts | 3 + .../ui-theme/tests/appearance-row.spec.tsx | 2 +- .../ui-tool/tests/chat-code-subcalls.spec.tsx | 2 +- .../ui-tool/tests/coverage-tails.spec.tsx | 2 +- .../client/ui-tool/tests/diff-card.spec.tsx | 6 +- .../client/ui-tool/tests/read-card.spec.tsx | 6 +- .../client/ui-tool/tests/search-card.spec.tsx | 2 +- .../ui-tool/tests/terminal-card.spec.tsx | 8 +- .../client/ui-tool/tests/web-card.spec.tsx | 2 +- .../client/ui-trajectory/tests/views.spec.tsx | 2 +- .../client/ui-workspace/tests/tree.spec.ts | 2 +- .../tests/workspace-browser.spec.tsx | 2 +- .../tests/workspace-picker.spec.tsx | 2 +- .../cordis/tool-cordis/src/api-catalog.ts | 8 + packages/host/apiproxy/README.i18n.yaml | 4 +- packages/host/apiproxy/README.md | 2 + packages/host/apiproxy/README.zh.md | 2 + packages/host/apiproxy/package.json | 1 + packages/host/apiproxy/src/api-proxy.ts | 52 +++- .../host/apiproxy/src/api/events.schema.ts | 2 + packages/host/apiproxy/src/api/events.ts | 15 ++ packages/host/apiproxy/src/api/index.ts | 1 + .../host/apiproxy/src/api/tasks.schema.ts | 33 +++ packages/host/apiproxy/src/api/tasks.ts | 36 +++ .../apiproxy/tests/api-proxy-tasks.spec.ts | 206 +++++++++++++++ .../host/apiproxy/tests/rpc-schemas.spec.ts | 13 + packages/host/apiproxy/tsconfig.json | 3 + packages/tasks/tasks-local/src/index.ts | 41 ++- .../tasks/tasks-local/tests/tasks.spec.ts | 93 +++++++ packages/tasks/tasks/README.i18n.yaml | 4 +- packages/tasks/tasks/README.md | 1 + packages/tasks/tasks/README.zh.md | 1 + packages/tasks/tasks/package.json | 5 + packages/tasks/tasks/src/brand.ts | 28 ++ packages/tasks/tasks/src/index.ts | 21 +- packages/tasks/tasks/src/types.ts | 28 +- packages/tasks/tasks/tests/service.spec.ts | 10 +- pnpm-lock.yaml | 40 +++ scripts/gen-cordis-catalog.ts | 1 + .../verify-package-readme-model-experience.ts | 1 + tsconfig.base.json | 2 + tsconfig.client.json | 1 + tsconfig.host.json | 1 + 93 files changed, 2130 insertions(+), 68 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-08-web-background-task-display.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-08-web-background-task-display.md create mode 100644 .agents/notes/implemented/feature/2026-08-08-web-background-task-display.zh.md create mode 100644 apps/web/tests/background-task-list.e2e.ts create mode 100644 apps/web/tests/snapshots/background-task-list/running.expected.md create mode 100644 apps/web/tests/snapshots/background-task-list/settled.expected.md create mode 100644 packages/client/ui-task/README.i18n.yaml create mode 100644 packages/client/ui-task/README.md create mode 100644 packages/client/ui-task/README.zh.md create mode 100644 packages/client/ui-task/package.json create mode 100644 packages/client/ui-task/src/client/TaskListAction.module.css create mode 100644 packages/client/ui-task/src/client/TaskListAction.tsx create mode 100644 packages/client/ui-task/src/client/index.ts create mode 100644 packages/client/ui-task/src/client/locales.ts create mode 100644 packages/client/ui-task/src/css-modules.d.ts create mode 100644 packages/client/ui-task/src/index.ts create mode 100644 packages/client/ui-task/src/invariant.ts create mode 100644 packages/client/ui-task/tests/browser-plugin.spec.ts create mode 100644 packages/client/ui-task/tests/task-list-action.spec.tsx create mode 100644 packages/client/ui-task/tsconfig.json create mode 100644 packages/client/ui-task/tsdown.config.ts create mode 100644 packages/host/apiproxy/src/api/tasks.schema.ts create mode 100644 packages/host/apiproxy/src/api/tasks.ts create mode 100644 packages/host/apiproxy/tests/api-proxy-tasks.spec.ts create mode 100644 packages/tasks/tasks/src/brand.ts 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..f00211b5f4 --- /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: 17ac109082ab3b2afea92194d0d148cf11ba9d0a +2026-08-08-web-background-task-display.zh.md: 1049218337caae3bd95c6f6ff3183589d0ce9d15 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..17ac109082 --- /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.** An unowned task is invisible in the header while `task_list` still reports it to the model. In practice every tool call carries an agent, so this stays theoretical, but the two surfaces are not interchangeable. + +**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..1049218337 --- /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 里不可见,而 `task_list` 仍会把它报告给模型。实践中每次工具调用都带着 agent,所以这停留在理论层面,但两个界面并不可互换。 + +**终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。 + +**`stopping` 今天几乎不可达。** 只有模型的 `task_kill` 会产生它,所以这个状态会被渲染但在人类中断落地之前很少见到。现在就纳入联合类型,是因为把它留在外面会让那一期变成一次线路变更。 + +**一个运行中的 subagent 有两个入口。** 这是刻意接受的,且被限制在一次性后台委派这一种情况。如果实际用起来读着像噪声,修法是呈现层的——可以让目录行引用那个任务,而不是让任务列表隐藏这个 kind。 + +**新增非根子路径必须补 `paths` 条目。** `@deepseek-ai/dsh-tasks/brand` 得先登记进 `tsconfig.base.json`,TypeRT 分析器才会接受该引用。它的故障表现是一条来自远离改动处的生成器的、令人困惑的「not exported by」错误,所以这个条目是新增子路径的组成部分,而不是优化。 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..217eda49fa --- /dev/null +++ b/apps/web/tests/background-task-list.e2e.ts @@ -0,0 +1,130 @@ +// 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')) + const trigger = page.getByRole('button', { name: '1 background task running' }) + await expect.poll(() => trigger.count(), { timeout: 5_000 }).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/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/tsconfig.json b/apps/web/tsconfig.json index 354781e934..12202b6457 100644 --- a/apps/web/tsconfig.json +++ b/apps/web/tsconfig.json @@ -67,6 +67,7 @@ "tests/goal-bar.e2e.ts", "tests/subagent-conversation.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/docs/config-catalog.md b/docs/config-catalog.md index 1f81974b21..01518ad6cd 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -2565,6 +2565,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)) diff --git a/docs/cordis-catalog/services.md b/docs/cordis-catalog/services.md index a22c3464eb..fe64a868a6 100644 --- a/docs/cordis-catalog/services.md +++ b/docs/cordis-catalog/services.md @@ -2338,6 +2338,22 @@ 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, the stopping transition, settlement, and owner-disposal + * removal — so an observer re-reads rather than accumulating deltas. + * + * 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. {@link start} * refuses work while none is attached. @@ -2347,9 +2363,9 @@ abstract onTaskDone(listener: TaskDoneListener): () => void abstract attachSurface(name: string): () => void ``` -Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-data-structures/tasks.md) · [TaskId](../core-data-structures/tasks.md) · [TaskRead](../core-data-structures/tasks.md) · [TaskSnapshot](../core-data-structures/tasks.md) · [TaskStart](../core-data-structures/tasks.md) +Types: [Agent](../core-data-structures/core.md) · [TaskDoneListener](../core-data-structures/tasks.md) · [TaskId](../core-data-structures/tasks.md) · [TaskRead](../core-data-structures/tasks.md) · [TaskSnapshot](../core-data-structures/tasks.md) · [TaskStart](../core-data-structures/tasks.md) · [TasksChangedListener](../core-data-structures/tasks.md) -Source: [`packages/tasks/tasks/src/index.ts:50`](../../packages/tasks/tasks/src/index.ts) +Source: [`packages/tasks/tasks/src/index.ts:53`](../../packages/tasks/tasks/src/index.ts) ## `ctx.telemetry` — `Telemetry` (abstract seam) diff --git a/docs/core-data-structures/tasks.i18n.yaml b/docs/core-data-structures/tasks.i18n.yaml index 58920a53df..0e2166d994 100644 --- a/docs/core-data-structures/tasks.i18n.yaml +++ b/docs/core-data-structures/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/core-data-structures/tasks.md -tasks.md: a38055d3ef7aa18e62678f92eb5ac5ae2a09c205 -tasks.zh.md: f34d42e713c3a0c11cbf88d52e573bb100c52493 +tasks.md: e07fa4798d9d7585fd1036346f8df39068884835 +tasks.zh.md: d958be308f462db371d10240cfa369a892fe1ce0 diff --git a/docs/core-data-structures/tasks.md b/docs/core-data-structures/tasks.md index a38055d3ef..e07fa4798d 100644 --- a/docs/core-data-structures/tasks.md +++ b/docs/core-data-structures/tasks.md @@ -151,4 +151,4 @@ interface TaskRead { ## Service behavior -The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) seam defines atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` listeners, and the `attachSurface` availability fence; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local implementation. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the seam contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing surface. +The abstract [`TaskService`](../../packages/tasks/tasks/src/index.ts) seam defines atomic `start`, caller-scoped `get` and `list`, `read`, `kill`, bounded `wait`, contained `onTaskDone` and `onTasksChanged` listeners, and the `attachSurface` availability fence; [`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) is the process-local implementation. The two listener kinds are not nested: `onTaskDone` delivers one terminal record under first-wins semantics a control surface couples to notice delivery, while `onTasksChanged` observes every visible-set change — registration, the stopping transition, settlement, and owner-disposal removal — carrying only the owner whose set moved, or `undefined` when an unowned task changed and every caller's set moved with it. Authorization compares owner sessions; owner cleanup selects the exact registered `Agent` instance. See [`dsh-tasks`](../../packages/tasks/tasks/README.md) for the seam contract, [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md) for the registry lifecycle, and [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md) for the model-facing surface. diff --git a/docs/core-data-structures/tasks.zh.md b/docs/core-data-structures/tasks.zh.md index f34d42e713..d958be308f 100644 --- a/docs/core-data-structures/tasks.zh.md +++ b/docs/core-data-structures/tasks.zh.md @@ -151,4 +151,4 @@ interface TaskRead { ## 服务行为 -抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) seam 定义原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 监听器,以及 `attachSurface` 可用性防线;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部实现。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。seam 契约见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的接口见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 +抽象的 [`TaskService`](../../packages/tasks/tasks/src/index.ts) seam 定义原子 `start`、限定调用方作用域的 `get` 和 `list`、`read`、`kill`、有界 `wait`、故障隔离的 `onTaskDone` 与 `onTasksChanged` 监听器,以及 `attachSurface` 可用性防线;[`LocalTaskService`](../../packages/tasks/tasks-local/src/index.ts) 是其进程局部实现。两类监听器不是包含关系:`onTaskDone` 按控制面与通知投递绑定的 first-wins 语义投递唯一一条终态记录,而 `onTasksChanged` 观察每一次可见集合的变化——注册、转入 stopping、结算,以及 owner 销毁时的移除——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。授权会比较拥有者会话;拥有者清理会选择确切的已注册 `Agent` 实例。seam 契约见 [`dsh-tasks`](../../packages/tasks/tasks/README.md),注册表生命周期见 [`dsh-tasks-local`](../../packages/tasks/tasks-local/README.md),面向模型的接口见 [`dsh-tool-tasks`](../../packages/tasks/tool-tasks/README.md)。 diff --git a/packages/bundle/web-app/cordis.patch.yml b/packages/bundle/web-app/cordis.patch.yml index 62ed35bd49..5c7b7d83a4 100644 --- a/packages/bundle/web-app/cordis.patch.yml +++ b/packages/bundle/web-app/cordis.patch.yml @@ -176,6 +176,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' diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index 9b8e15fd66..bd5ac23b11 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -54,6 +54,7 @@ "@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:^", diff --git a/packages/client/README.i18n.yaml b/packages/client/README.i18n.yaml index e65d5246a4..d1f8676a91 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: b6fa426fbe541e2b22d2bf5f19d4397361cf0899 -README.zh.md: 5a55bb8c2c31b5215fc73e75e1c4f3aca79add64 +README.md: 347a42e0671ad0ae1da6c5488f94f387e85fcf99 +README.zh.md: 596ce3910430a183a9750c94b83cb79f43ce037f diff --git a/packages/client/README.md b/packages/client/README.md index b6fa426fbe..347a42e067 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 5a55bb8c2c..596ce39104 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/src/client/api.ts b/packages/client/connection/src/client/api.ts index 6f29b2dda0..b59f7805c9 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/index.ts b/packages/client/connection/src/client/index.ts index c2a6668d46..52bf8c699d 100644 --- a/packages/client/connection/src/client/index.ts +++ b/packages/client/connection/src/client/index.ts @@ -22,6 +22,7 @@ export type { ModelCatalogFailure, ModelCatalogModel, ModelProviderGroup, ModelReasoning, MessageId, ModelReasoningEffort, ModelTarget, QueueAction, QueuedInboxItem, SessionModels, SubagentsApi, SubagentAddress, SubagentCatalog, SubagentListEntry, SubagentPromptReceipt, + TaskView, RpcRequest, RpcResponse, RpcResult, RpcError, RpcErrorCode, ClientRequest, ServerResponse, ServerRequest, ClientResponse, RpcMessage, RpcReceipt, IApiClient, SessionId, SessionEvent, ContentBlock, StreamChunk, 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/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 1edac07d5e..438ac72f70 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: a9b604974595b1b7856f74b72d36093491ec1bd1 -README.zh.md: 6eee14bdbe7a9fb86b0a12355e7d0a45cd500774 +README.md: 5abe72767aa76d5690b3f287391bc8924bc62d03 +README.zh.md: 78024fa095a5bcddc50ee121eec64e887f241ba7 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index a9b6049745..5abe72767a 100644 --- a/packages/client/runtime/README.md +++ b/packages/client/runtime/README.md @@ -24,6 +24,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 diff --git a/packages/client/runtime/README.zh.md b/packages/client/runtime/README.zh.md index 6eee14bdbe..78024fa095 100644 --- a/packages/client/runtime/README.zh.md +++ b/packages/client/runtime/README.zh.md @@ -24,6 +24,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 镜像 diff --git a/packages/client/runtime/src/client/index.ts b/packages/client/runtime/src/client/index.ts index b7226c0085..7fceebaa24 100644 --- a/packages/client/runtime/src/client/index.ts +++ b/packages/client/runtime/src/client/index.ts @@ -36,7 +36,7 @@ export type { SessionBinding, SessionListState, SessionProvideContribution, SessionProvideDescriptor, SessionSummary, } from './sessions/service.ts' export type { SessionListPhase, SessionSearchResultItem, SubagentCatalogSnapshot } from './sessions/manager.ts' -export type { SubagentAddress } from '@deepseek-ai/dsh-client-connection/client' +export type { SubagentAddress, TaskView } from '@deepseek-ai/dsh-client-connection/client' export type { WorkspaceListPhase } from './workspaces/manager.ts' export type { WorkspaceListState } from './workspaces/service.ts' export type { diff --git a/packages/client/runtime/src/client/sessions/manager.ts b/packages/client/runtime/src/client/sessions/manager.ts index 64199c4812..5a6d7ddbfa 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. @@ -47,6 +47,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 } @@ -137,6 +139,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 @@ -657,10 +664,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 @@ -770,6 +790,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 @@ -1006,6 +1031,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/service.ts b/packages/client/runtime/src/client/sessions/service.ts index 621760df02..62b02b716f 100644 --- a/packages/client/runtime/src/client/sessions/service.ts +++ b/packages/client/runtime/src/client/sessions/service.ts @@ -17,7 +17,7 @@ */ import type { Context, Fiber } from '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. @@ -80,6 +80,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 } @@ -271,7 +277,7 @@ export class SessionsService implements ISessions { this.manager = new SessionManager(api, restored.sessionId, restored.subagentAddress) 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. @@ -606,7 +612,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 = {} @@ -675,7 +681,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/tests/manager.spec.ts b/packages/client/runtime/tests/manager.spec.ts index e203e49dd9..c63dfbe4de 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/test-runtime/src/sessions.ts b/packages/client/test-runtime/src/sessions.ts index 4747929572..ee957447d3 100644 --- a/packages/client/test-runtime/src/sessions.ts +++ b/packages/client/test-runtime/src/sessions.ts @@ -192,7 +192,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/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index 38d643292e..a097cc7fa9 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -110,7 +110,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/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 3afe471d50..c008305799 100644 --- a/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx +++ b/packages/client/ui-conversation/tests/gate-branch-tails.spec.tsx @@ -100,7 +100,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, @@ -149,7 +149,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/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 47791a6090..b0052f9919 100644 --- a/packages/client/ui-conversation/tests/input-bar.spec.tsx +++ b/packages/client/ui-conversation/tests/input-bar.spec.tsx @@ -109,7 +109,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, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index f197528a2b..e81a44e485 100644 --- a/packages/client/ui-conversation/tests/input-matrix.spec.tsx +++ b/packages/client/ui-conversation/tests/input-matrix.spec.tsx @@ -37,7 +37,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 c47b726b66..6971e72a2d 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -123,7 +123,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/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 70b6d96710..3755a68cb1 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -107,7 +107,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) diff --git a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index c300e423e6..a4b5d07709 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 { diff --git a/packages/client/ui-task/README.i18n.yaml b/packages/client/ui-task/README.i18n.yaml new file mode 100644 index 0000000000..45157f12e5 --- /dev/null +++ b/packages/client/ui-task/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/client/ui-task/README.md +README.md: 318984808838cb7a336a4c34c95a2f27db578423 +README.zh.md: 7adeb254662fd23cf4bd1efc8618471cf74df7c0 diff --git a/packages/client/ui-task/README.md b/packages/client/ui-task/README.md new file mode 100644 index 0000000000..3189848088 --- /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 the owning session can see through the wire view, so an unowned task (one started without a live `Agent`) is invisible here while `task_list` still reports it to the model. diff --git a/packages/client/ui-task/README.zh.md b/packages/client/ui-task/README.zh.md new file mode 100644 index 0000000000..7adeb25466 --- /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()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。 +- **列表不等于注册表自己的集合** —— 它展示的是拥有它的会话通过线路视图能看到的东西,因此一个无主任务(在没有活体 `Agent` 时启动的任务)在这里不可见,而 `task_list` 仍会把它报告给模型。 diff --git a/packages/client/ui-task/package.json b/packages/client/ui-task/package.json new file mode 100644 index 0000000000..439bacfd6f --- /dev/null +++ b/packages/client/ui-task/package.json @@ -0,0 +1,68 @@ +{ + "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", + "private": true, + "type": "module", + "main": "lib/index.js", + "types": "lib/types/index.d.ts", + "exports": { + ".": { + "types": "./lib/types/index.d.ts", + "default": "./lib/index.js" + }, + "./invariant": { + "types": "./lib/types/invariant.d.ts", + "default": "./lib/invariant.js" + }, + "./client": { + "types": "./lib/types/client/index.d.ts", + "default": "./lib/client.js" + }, + "./src/*": "./src/*", + "./package.json": "./package.json" + }, + "dshClient": { + "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", + "dependencies": { + "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-slots": "^0.0.1", + "@deepseek-ai/dsh-invariants": "^0.0.1", + "cordis": "^4.0.0-rc.7" + }, + "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", + "cordis": "^4.0.0-rc.7" + }, + "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..6bf6ed2507 --- /dev/null +++ b/packages/client/ui-task/src/client/TaskListAction.module.css @@ -0,0 +1,116 @@ +.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; +} + +.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..722d317aa9 --- /dev/null +++ b/packages/client/ui-task/src/client/TaskListAction.tsx @@ -0,0 +1,185 @@ +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/client/ui-task/src/invariant.ts b/packages/client/ui-task/src/invariant.ts new file mode 100644 index 0000000000..b236094959 --- /dev/null +++ b/packages/client/ui-task/src/invariant.ts @@ -0,0 +1,32 @@ +/** + * 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 { InvariantInstaller } from '@deepseek-ai/dsh-invariants' + +const PACKAGE_NAME = '@deepseek-ai/dsh-client-ui-task' + +/** Cordis companion plugin name. */ +export const name = 'client-ui-task-invariant' +/** Service required before the companion can reserve package ownership. */ +export const inject = ['invariants'] + +/** + * 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 = () => {} + +/** + * 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/client/ui-task/tests/browser-plugin.spec.ts b/packages/client/ui-task/tests/browser-plugin.spec.ts new file mode 100644 index 0000000000..3d6ba4cf94 --- /dev/null +++ b/packages/client/ui-task/tests/browser-plugin.spec.ts @@ -0,0 +1,89 @@ +/** + * 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 '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 } 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', {}) + await ctx.plugin({ inject: ['slots'], 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/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-tool/tests/chat-code-subcalls.spec.tsx b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx index 2f5efae420..00150efdcc 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -101,7 +101,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 7713bedaec..7239e10653 100644 --- a/packages/client/ui-tool/tests/diff-card.spec.tsx +++ b/packages/client/ui-tool/tests/diff-card.spec.tsx @@ -157,7 +157,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 +309,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({ diff --git a/packages/client/ui-tool/tests/read-card.spec.tsx b/packages/client/ui-tool/tests/read-card.spec.tsx index 739a787a03..25bf699529 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -171,7 +171,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 +255,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({ diff --git a/packages/client/ui-tool/tests/search-card.spec.tsx b/packages/client/ui-tool/tests/search-card.spec.tsx index 51cab2d440..98bd10131d 100644 --- a/packages/client/ui-tool/tests/search-card.spec.tsx +++ b/packages/client/ui-tool/tests/search-card.spec.tsx @@ -378,7 +378,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, diff --git a/packages/client/ui-tool/tests/terminal-card.spec.tsx b/packages/client/ui-tool/tests/terminal-card.spec.tsx index c9e2875a34..722dc9b66c 100644 --- a/packages/client/ui-tool/tests/terminal-card.spec.tsx +++ b/packages/client/ui-tool/tests/terminal-card.spec.tsx @@ -346,7 +346,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 +446,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({ @@ -643,7 +643,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 7856ae9d6f..4ea8b713b2 100644 --- a/packages/client/ui-tool/tests/web-card.spec.tsx +++ b/packages/client/ui-tool/tests/web-card.spec.tsx @@ -208,7 +208,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, diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 2a0ec68ec0..d8dfbd24f6 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -122,7 +122,7 @@ function fakeSession(nodes: ConversationSnapshot['nodes']) { /** 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) } 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/cordis/tool-cordis/src/api-catalog.ts b/packages/cordis/tool-cordis/src/api-catalog.ts index dec95f3b5f..a138dc89ff 100644 --- a/packages/cordis/tool-cordis/src/api-catalog.ts +++ b/packages/cordis/tool-cordis/src/api-catalog.ts @@ -1030,6 +1030,10 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ signature: 'abstract onTaskDone(listener: TaskDoneListener): () => void', jsDoc: '/**\n * Register an effect-scoped completion listener. Each listener is contained;\n * returned promises are observed but not awaited. No listener runs after\n * 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 * 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, the stopping transition, settlement, and owner-disposal\n * removal — so an observer re-reads rather than accumulating deltas.\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. {@link start}\n * refuses work while none is attached.\n * @param name - diagnostic label; duplicate names remain independent.\n * @returns disposer that detaches this surface.\n */', @@ -2915,6 +2919,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/host/apiproxy/README.i18n.yaml b/packages/host/apiproxy/README.i18n.yaml index 627f624235..73f531e34c 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: a3c9f214690144ec0f39a8690e4fd346f5e315e2 -README.zh.md: aeaf1b29e5a71674c9feedb30b67f9ce11c47340 +README.md: edf51dd1c15c61a03102003eb921a0e21b1f0fa8 +README.zh.md: 6b8bfa0459167b87c5b2848eb683cabf7ce91444 diff --git a/packages/host/apiproxy/README.md b/packages/host/apiproxy/README.md index a3c9f21469..edf51dd1c1 100644 --- a/packages/host/apiproxy/README.md +++ b/packages/host/apiproxy/README.md @@ -36,6 +36,8 @@ Session model routing is a session-domain contract. `session.models` returns the 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({ name })` creates a uniquely titled directory under the configured root, while `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. diff --git a/packages/host/apiproxy/README.zh.md b/packages/host/apiproxy/README.zh.md index aeaf1b29e5..6b8bfa0459 100644 --- a/packages/host/apiproxy/README.zh.md +++ b/packages/host/apiproxy/README.zh.md @@ -36,6 +36,8 @@ 待处理的 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({ name })` 会在配置根目录下创建显示标题唯一的目录,而 `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 变量上限。 diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 91d03b4448..30153e27a0 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -56,6 +56,7 @@ "@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:^", diff --git a/packages/host/apiproxy/src/api-proxy.ts b/packages/host/apiproxy/src/api-proxy.ts index cfcae423bc..c51b8b58e8 100644 --- a/packages/host/apiproxy/src/api-proxy.ts +++ b/packages/host/apiproxy/src/api-proxy.ts @@ -30,7 +30,7 @@ import type { ApiProxy, ConfigurableProviderView, CredentialView, GoalRef, HistoryEntry, HostFrame, ModelCatalogFailure, ModelProviderGroup, ModelReasoning, MuxFrame, QuestionResponsePayload, SessionProjectionsBlock, SessionSearchItem, - QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, ToolEventView, + QueuedInboxItem, SessionSummary, SettingsNamespaceView, SubagentAddress, TaskView, ToolEventView, WorkspaceId, WorkspaceView, } from './api/index.ts' import { @@ -40,6 +40,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. @@ -261,6 +264,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 @@ -2586,6 +2605,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. @@ -2614,6 +2646,24 @@ export function createApiProxy(ctx: Context, defaults: ApiProxyDefaults): ApiPro 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) diff --git a/packages/host/apiproxy/src/api/events.schema.ts b/packages/host/apiproxy/src/api/events.schema.ts index 13ead7d08d..577a40d293 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 shape 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 7fc54f6f01..ac8ef6638d 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 cb83c5328d..ff8b4fa6ad 100644 --- a/packages/host/apiproxy/src/api/index.ts +++ b/packages/host/apiproxy/src/api/index.ts @@ -44,6 +44,7 @@ export type { DirectoryEntry, DirectoryListing, HostApi } from './host.ts' export type { SubagentAddress, SubagentCatalog, 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' 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/tests/api-proxy-tasks.spec.ts b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts new file mode 100644 index 0000000000..deabe7741a --- /dev/null +++ b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts @@ -0,0 +1,206 @@ +/** + * 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 '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 + const spec = { + kind: 'bash' as const, + label, + run: () => ({ + cancel: () => {}, + done: new Promise((resolve) => { settle = resolve }), + }), + } + return { spec, 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, { defaultTarget: () => ({ provider: 'p', model: 'm' }), cwd: '/tmp', workspaceRoot: '/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) + }) +}) diff --git a/packages/host/apiproxy/tests/rpc-schemas.spec.ts b/packages/host/apiproxy/tests/rpc-schemas.spec.ts index 75b6dff3f7..50a3b164f9 100644 --- a/packages/host/apiproxy/tests/rpc-schemas.spec.ts +++ b/packages/host/apiproxy/tests/rpc-schemas.spec.ts @@ -433,6 +433,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 }) @@ -441,6 +446,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/tsconfig.json b/packages/host/apiproxy/tsconfig.json index 8686a9e468..a617f5b804 100644 --- a/packages/host/apiproxy/tsconfig.json +++ b/packages/host/apiproxy/tsconfig.json @@ -65,6 +65,9 @@ { "path": "../../skill/skill" }, + { + "path": "../../tasks/tasks" + }, { "path": "../../ui/commands" }, diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index f108022b17..0e0d3479b0 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -13,7 +13,10 @@ import { Context } from 'cordis' import type { Agent } from '@deepseek-ai/dsh-agent' 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' @@ -59,6 +62,7 @@ export class LocalTaskService extends TaskService { private counters = new Map() private surfaces = new Set() private listeners = new Set() + private changeListeners = new Set() private listenersClosed = false /** Owner agents with attached scope cleanup, mapped to the exact disposer. */ private ownerCleanups = new Map Promise | void>() @@ -119,6 +123,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 } @@ -156,6 +163,7 @@ export class LocalTaskService extends TaskService { task.cancel(reason) task.status = 'stopping' task.reported = true + this.notifyChanged(task.owner) return 'requested' } @@ -217,6 +225,14 @@ export class LocalTaskService extends TaskService { return () => void dispose() } + onTasksChanged(listener: TasksChangedListener): () => void { + const dispose = this.ctx.effect(() => { + this.changeListeners.add(listener) + return () => this.changeListeners.delete(listener) + }, 'tasks.onTasksChanged()') + return () => void dispose() + } + attachSurface(name: string): () => void { // One token per call keeps duplicate labels independently disposable. const token = Symbol(name) @@ -262,6 +278,20 @@ export class LocalTaskService extends TaskService { } } + /** + * 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.changeListeners) { + 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 @@ -291,6 +321,7 @@ export class LocalTaskService extends TaskService { task.waitResolvers.clear() for (const resolveWait of waitResolvers) resolveWait() task.markSettled() + this.notifyChanged(task.owner) } /** @@ -323,6 +354,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) } /** @@ -336,6 +370,11 @@ export class LocalTaskService extends TaskService { this.cancelForTeardown(all, 'tasks service disposed') await Promise.all(all.map(task => task.settled)) this.store.clear() + // No change notification here: every `onTasksChanged` registration is an + // effect on this service's own fiber, so the listeners are already gone by + // the time service teardown reaches this line. An observer learns the + // registry left through its own disposal, not through a final empty set. + this.changeListeners.clear() // Detach cross-fiber owner effects after the shared store is quiescent. const ownerCleanups = [...this.ownerCleanups.values()] this.ownerCleanups.clear() diff --git a/packages/tasks/tasks-local/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index 29d859760f..ebd1e73b52 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -760,3 +760,96 @@ describe('LocalTaskService disposal', () => { expect(() => ctx.tasks.start(producer().spec)).toThrow('no control surface is attached') }) }) + +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]) + }) +}) diff --git a/packages/tasks/tasks/README.i18n.yaml b/packages/tasks/tasks/README.i18n.yaml index 7ceb8ff432..3167c00cf4 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: 2f822bad139020f0ebae0165aa4e8893853f635d -README.zh.md: fdc619fbb46267b2ae550c85cb14fcf8a916f638 +README.md: e7431cca0956429788274b0d44a249dd1e374156 +README.zh.md: e8c1162f66471eee3c04111c8617dd84295dc6cf diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 2f822bad13..e7431cca09 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -12,6 +12,7 @@ The background task registry seam (`ctx.tasks`). The abstract `TaskService` and - `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, the stopping transition, settlement, and owner-disposal removal — 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. - `attachSurface(name)` declares a control surface for its effect lifetime. `start()` fails before producer execution when none is attached. 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 fdc619fbb4..e8c1162f66 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -12,6 +12,7 @@ - `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 - `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。 - `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。 +- `onTasksChanged(listener)` 观察可见集合的变化——注册、转入 stopping、结算,以及 owner 销毁时的移除——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。 - `attachSurface(name)` 在其 effect 生命周期内声明控制表层。如果没有附加任何表层,`start()` 会在生产方执行前失败。 有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。 diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index eea8e2af6b..d20d16d7c9 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -15,12 +15,17 @@ "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", 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 e237aa0681..27a07d1da3 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -8,7 +8,9 @@ import { Context, Service } from '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,6 +23,7 @@ export type { TaskSnapshot, TaskStart, TaskStatus, + TasksChangedListener, } from './types.ts' declare module 'cordis' { @@ -129,6 +132,22 @@ 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, the stopping transition, settlement, and owner-disposal + * removal — so an observer re-reads rather than accumulating deltas. + * + * 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. {@link start} * refuses work while none is attached. 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/service.spec.ts b/packages/tasks/tasks/tests/service.spec.ts index 82fc415f51..ccd3106c22 100644 --- a/packages/tasks/tasks/tests/service.spec.ts +++ b/packages/tasks/tasks/tests/service.spec.ts @@ -2,7 +2,9 @@ import { describe, expect, it } from 'vitest' import { Context } from '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 seam 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/pnpm-lock.yaml b/pnpm-lock.yaml index ccbc67c764..e0043229cc 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1281,6 +1281,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 @@ -2324,6 +2327,40 @@ importers: specifier: ^4.0.0-rc.7 version: link:../../../vendor/cordis + packages/client/ui-task: + dependencies: + react: + specifier: ^18.2.0 + version: 18.3.1 + devDependencies: + '@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 + cordis: + specifier: ^4.0.0-rc.7 + version: link:../../../vendor/cordis + packages/client/ui-theme: dependencies: clsx: @@ -3898,6 +3935,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 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 54581a8605..f1a5d6e965 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -191,6 +191,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', diff --git a/scripts/verify-package-readme-model-experience.ts b/scripts/verify-package-readme-model-experience.ts index 3ca0317cf4..d0fdef95bd 100644 --- a/scripts/verify-package-readme-model-experience.ts +++ b/scripts/verify-package-readme-model-experience.ts @@ -65,6 +65,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 target at the next prompt-assembly boundary and owns the model-visible effect.' }, diff --git a/tsconfig.base.json b/tsconfig.base.json index de9b63459c..274b75d37a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -71,6 +71,7 @@ "@deepseek-ai/dsh-llm-retry/types": ["./packages/llm/llm-retry/src/types.ts"], "@deepseek-ai/dsh-llm/message": ["./packages/llm/llm/src/message.ts"], "@deepseek-ai/dsh-commands/brand": ["./packages/ui/commands/src/brand.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-tools/presentation": ["./packages/core/tools/src/presentation.ts"], "@deepseek-ai/dsh-tool-subagent-control/list-agents": ["./packages/subagent/tool-subagent-control/src/list-agents.ts"], @@ -171,6 +172,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 9ce72753c1..f03e452a57 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-permission" }, diff --git a/tsconfig.host.json b/tsconfig.host.json index 6641a505b9..5e1b0a8e70 100644 --- a/tsconfig.host.json +++ b/tsconfig.host.json @@ -54,6 +54,7 @@ "apps/web/tests/produced-files.e2e.ts", "apps/web/tests/subagent-conversation.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", From 37a75f9b7f2292849a856f46c7f48fa2020e65d1 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Sun, 9 Aug 2026 00:00:33 +0800 Subject: [PATCH 004/113] docs: regenerate the module graph for client-ui-task The new package and its dependency edges changed the generated graph; `verify-module-graph` runs in the CI static gate rather than doc-sync, so the stale file only surfaced after the push. --- docs/module-graph.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/module-graph.md b/docs/module-graph.md index 22af77f9c5..04e1870dae 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -183,6 +183,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"] @@ -892,6 +893,12 @@ flowchart TD pkg_client_ui_goal --> pkg_client_ui_slots pkg_client_ui_goal --> pkg_goal pkg_client_ui_goal --> pkg_invariants + 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 @@ -1317,6 +1324,7 @@ flowchart TD | [`client-ui-command`](../packages/client/ui-command) | `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-slash`](../packages/client/ui-slash), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`client-ui-deliverables`](../packages/client/ui-deliverables) | `client` | [`client-locale`](../packages/client/locale), [`client-runtime`](../packages/client/runtime), [`client-ui-conversation`](../packages/client/ui-conversation), [`client-ui-slots`](../packages/client/ui-slots), [`invariants`](../packages/support/invariants) | | [`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-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) | | [`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) | From 1db1cda4644dd5df46c149e4c36faba04c9d67d3 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 11:15:01 +0800 Subject: [PATCH 005/113] fix(subagent): keep output past an empty terminal message with one selection rule A max-tokens step that assembled only tool-call blocks appends an EMPTY-content assistant/message (the usage host). Three consumers each hand-rolled output selection and all let it erase the child's real answer: the in-process readResult and the Activation subagent/end capture took the last message unfiltered, and the SDK backend let any message beat its streamed-text fallback; the in-process driver also had no streamed-text fallback for cancelled turns. dsh-subagent now owns the canonical rule in src/assistant-output.ts (last non-empty assistant message, else the accumulated text-delta stream) and all three consumers apply it. Regression tests in all three packages fail under the previous selections. Closes #1514 --- ...nt-empty-terminal-message-output.i18n.yaml | 6 ++ ...-subagent-empty-terminal-message-output.md | 27 +++++++++ ...bagent-empty-terminal-message-output.zh.md | 27 +++++++++ docs/event-producer-consumer.i18n.yaml | 4 +- docs/event-producer-consumer.md | 8 +-- docs/event-producer-consumer.zh.md | 8 +-- docs/subsystems/subagent.i18n.yaml | 4 +- docs/subsystems/subagent.md | 17 ++++-- docs/subsystems/subagent.zh.md | 17 ++++-- .../scaffold/client/tests/fake-runtime.ts | 8 ++- .../subagent-dsh-sdk/README.i18n.yaml | 4 +- packages/subagent/subagent-dsh-sdk/README.md | 2 +- .../subagent/subagent-dsh-sdk/README.zh.md | 2 +- packages/subagent/subagent-dsh-sdk/src/run.ts | 16 +++-- .../tests/subagent-dsh-sdk.spec.ts | 14 +++++ .../subagent-inprocess/README.i18n.yaml | 4 +- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../subagent/subagent-inprocess/src/index.ts | 7 ++- .../tests/subagent-inprocess.spec.ts | 39 +++++++++++- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/assistant-output.ts | 51 ++++++++++++++++ packages/subagent/subagent/src/index.ts | 1 + packages/subagent/subagent/src/lifecycle.ts | 16 +---- packages/subagent/subagent/src/types.ts | 13 +++- .../subagent/tests/assistant-output.spec.ts | 60 +++++++++++++++++++ .../subagent/tests/continuation.spec.ts | 43 ++++++++++++- 29 files changed, 344 insertions(+), 66 deletions(-) create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.i18n.yaml create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md create mode 100644 .agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md create mode 100644 packages/subagent/subagent/src/assistant-output.ts create mode 100644 packages/subagent/subagent/tests/assistant-output.spec.ts 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..9ec2d8bb33 --- /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: ece2a55930aabfaeaf88ef1757918296b32bfea5 +2026-08-10-subagent-empty-terminal-message-output.zh.md: 00b8f5bd7bd8e71935f68af2c35521f2b2377186 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..ece2a55930 --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.md @@ -0,0 +1,27 @@ +# 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 (`BlockAssembler.blocks()` drops truncated tool calls): the message exists solely to host usage. Three consumers each selected "the child's answer" with their own rule and all treated that usage host as the answer. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture took the LAST `assistant/message` unfiltered, and the SDK backend's observer let any `assistant/message` beat its streamed-text fallback. In a multi-step turn cut off at max-tokens, the final empty message therefore erased the real partial answer: `SubagentResult.output` came back `[]`, and the tool result, telemetry, and `subagent/end.lastAssistantMessage` all saw nothing. The in-process driver additionally had no streamed-text fallback at all, so a cancelled child whose only text lived in `assistant/chunk` events also reported `[]`. + +## Decision + +`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: the last NON-EMPTY assistant message wins; without one, the accumulated `text-delta` stream is the answer; empty-content messages never contribute. `finalAssistantOutput(events)` applies the rule to an event suffix (the in-process `readResult` and the Activation capture), and `assistantMessageOutput(event)` is the same per-event predicate for the SDK backend's incremental fold. The contract is stated once at `SubagentResult.output` and mirrored by the subsystem reference; `subagent/end.lastAssistantMessage` declares it selects by the same rule. A `max-tokens` or `aborted` finish still reports its honest stop reason; only output selection changed. + +The ACP backend accumulates chunks only and was never affected. The fake SDK runtime gained a `FAKE_EMPTY_MESSAGE` mode so the keyless backend test can script a usage-only terminal message. + +## Alternatives considered + +**Fix each consumer in place without a shared helper.** Rejected: the defect existed precisely because three hand-rolled selections drifted; observers of one run must agree on its answer, so the rule needs one implementation (the drafts that first proved the defect, PR #1140 and PR #1141, patched two of the three call sites separately and left the Activation capture inconsistent). + +**Stop the loop from appending the empty message.** Rejected: the message is the usage host and the step's durable record ("model-visible ⟺ logged"); reshaping session events for a consumer-side selection bug would touch 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 keep the text streamed before the abort; one-shot and continuable `subagent/end` edges agree with `SubagentResult.output`. A message whose content is non-empty but textless (for example reasoning-only) still wins over streamed text — the rule is about empty content, not text presence. Regression tests in all three packages script the empty-terminal-message and cancel paths and fail under the previous selections. 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..00b8f5bd7b --- /dev/null +++ b/.agents/notes/implemented/bug-fix/2026-08-10-subagent-empty-terminal-message-output.zh.md @@ -0,0 +1,27 @@ +# Agent Note: 用同一条选取规则在空终止消息后保留子代理输出 + +Status: implemented + +[English](2026-08-10-subagent-empty-terminal-message-output.md) | 中文 + +## 问题 + +当 `max-tokens` 步骤只组装了工具调用块时(`BlockAssembler.blocks()` 会丢弃被截断的工具调用),agent loop 会追加一条内容为**空**的 `assistant/message`——这条消息仅用于承载 usage。三个消费方各自用自己的规则选取"子代理的回答",并且都把这个 usage 宿主当成了回答:进程内驱动的 `readResult` 和 continuable Activation 的 `subagent/end` capture 不加过滤地取**最后一条** `assistant/message`,SDK 后端的观察器则让任何 `assistant/message` 覆盖其流式文本兜底。于是在被 max-tokens 截断的多步回合中,最后那条空消息抹掉了真实的部分回答:`SubagentResult.output` 返回 `[]`,工具结果、遥测和 `subagent/end.lastAssistantMessage` 全都看不到任何内容。此外进程内驱动完全没有流式文本兜底,因此被取消的子代理若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`。 + +## 决策 + +`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:最后一条**非空** assistant 消息优先;没有时,累积的 `text-delta` 流就是回答;空内容消息从不参与。`finalAssistantOutput(events)` 把该规则应用于事件后缀(进程内 `readResult` 与 Activation capture),`assistantMessageOutput(event)` 是同一规则的逐事件谓词,供 SDK 后端的增量折叠使用。契约在 `SubagentResult.output` 处声明一次,并由子系统参考文档镜像;`subagent/end.lastAssistantMessage` 声明按同一规则选取。`max-tokens` 或 `aborted` 终止仍然如实上报其终止原因;只有输出选取发生了变化。 + +ACP 后端只累积分块,从未受影响。fake SDK runtime 新增 `FAKE_EMPTY_MESSAGE` 模式,使无密钥后端测试能够脚本化一条仅承载 usage 的终止消息。 + +## 考虑过的替代方案 + +**各消费方就地修复、不抽共享辅助函数。** 之所以否决:缺陷恰恰源于三处手写选取的漂移;同一次运行的观察方必须对其回答达成一致,因此规则需要唯一实现(最早证明该缺陷的草稿 PR #1140 与 PR #1141 分别修补了三处调用点中的两处,留下 Activation capture 不一致)。 + +**让 loop 不再追加空消息。** 之所以否决:这条消息是 usage 宿主,也是该步骤的持久化记录("model-visible ⟺ logged");为一个消费方侧的选取缺陷重塑会话事件,会波及所有 replay 与 projection 消费方。 + +**把空内容消息视为错误。** 之所以否决:流式文本才是子代理真实的部分回答,且终止原因已经告诉消费方轮次被截断。 + +## 后果 + +被 max-tokens 截断的多步子代理会报告其更早的文本;被取消的进程内子代理保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 边沿与 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning)仍然优先于流式文本——规则针对的是内容为空,而非文本缺失。三个包中的回归测试脚本化了空终止消息与取消路径,并在先前的选取实现下失败。 diff --git a/docs/event-producer-consumer.i18n.yaml b/docs/event-producer-consumer.i18n.yaml index 0a3d6007f1..4b9645c425 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: 11eecf81a4eccadf2b97026154a78e4ed8a72164 -event-producer-consumer.zh.md: 2db5e596465b4adaf98c1b05692b61de3ced47b9 +event-producer-consumer.md: 1849bf5bd4df5e58370b1eaf5846f14a604fa4f6 +event-producer-consumer.zh.md: f280c50076fa2799e52556aa31a6d2316c151e17 diff --git a/docs/event-producer-consumer.md b/docs/event-producer-consumer.md index 11eecf81a4..1849bf5bd4 100644 --- a/docs/event-producer-consumer.md +++ b/docs/event-producer-consumer.md @@ -37,10 +37,10 @@ This matrix shows which packages dispatch each harness-owned event and which pac | `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:284`](../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) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../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:137`](../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:143`](../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:154`](../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`) | - | | `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`) | - | diff --git a/docs/event-producer-consumer.zh.md b/docs/event-producer-consumer.zh.md index 2db5e59646..f280c50076 100644 --- a/docs/event-producer-consumer.zh.md +++ b/docs/event-producer-consumer.zh.md @@ -39,10 +39,10 @@ | `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:284`](../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) | +| `subagent/end` | `emit` | [`packages/subagent/subagent/src/index.ts:163`](../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:137`](../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:143`](../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:154`](../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`) | - | | `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`) | - | diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index e98438900d..554c7a1662 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: 99c54e0696c9c3585c50285ecb69b14c90d83c11 -subagent.zh.md: c5a79247a81f5174662f2b5d1ed2d3c20cf6c672 +subagent.md: 4d72ca7a3fd42438e46a50558287e0e85450963d +subagent.zh.md: e8b1c948fa0ac3fe47097f0dd8f2532bdb4a8d20 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index 99c54e0696..4d72ca7a3f 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: the content of the last NON-EMPTY + * assistant message (an empty-content message hosts only usage and is + * skipped), else the text streamed before the turn was cut short, or `[]` + * when the child produced none. + */ readonly output: ContentBlock[] /** * The structured result after a requested `outputSchema` was successfully @@ -613,7 +618,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:168`](../../packages/subagent/subagent/src/index.ts) @@ -639,7 +644,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:163`](../../packages/subagent/subagent/src/index.ts) @@ -656,7 +661,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:137`](../../packages/subagent/subagent/src/index.ts) @@ -673,7 +678,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:143`](../../packages/subagent/subagent/src/index.ts) @@ -697,5 +702,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:154`](../../packages/subagent/subagent/src/index.ts) diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index c5a79247a8..e8b1c948fa 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: the content of the last NON-EMPTY + * assistant message (an empty-content message hosts only usage and is + * skipped), else the text streamed before the turn was cut short, or `[]` + * when the child produced none. + */ readonly output: ContentBlock[] /** * The structured result after a requested `outputSchema` was successfully @@ -615,7 +620,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:168`](../../packages/subagent/subagent/src/index.ts) @@ -641,7 +646,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:163`](../../packages/subagent/subagent/src/index.ts) @@ -658,7 +663,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:137`](../../packages/subagent/subagent/src/index.ts) @@ -675,7 +680,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:143`](../../packages/subagent/subagent/src/index.ts) @@ -699,5 +704,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:154`](../../packages/subagent/subagent/src/index.ts) diff --git a/packages/scaffold/client/tests/fake-runtime.ts b/packages/scaffold/client/tests/fake-runtime.ts index 85d5253765..0462fabf4a 100644 --- a/packages/scaffold/client/tests/fake-runtime.ts +++ b/packages/scaffold/client/tests/fake-runtime.ts @@ -26,6 +26,9 @@ * 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's assistant/message has EMPTY content (a + * usage-only max-tokens step) after streaming the text chunk — a consumer + * must keep the streamed text instead of the empty message. * - `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 +120,10 @@ function runTurn(sessionId: string): void { message: { id: `fake-assistant-${seq}`, role: 'assistant', - content: [{ type: 'text', text }], + // FAKE_EMPTY_MESSAGE: a usage-only terminal message (the harness loop + // appends one when a max-tokens step assembled no text blocks) whose + // empty content must not erase the text streamed above. + content: env.FAKE_EMPTY_MESSAGE !== undefined ? [] : [{ type: 'text', text }], source: { kind: 'model', provider: 'fake', model: 'fake' }, }, }) diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index b070f9f5d6..cbe3becb25 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: 80f5c40a2c949b7c2a638ec19c02950e8cd69f0b +README.zh.md: b34421dbbf2d06b7c9236776aabf19ba8005204e diff --git a/packages/subagent/subagent-dsh-sdk/README.md b/packages/subagent/subagent-dsh-sdk/README.md index 0bbcfa105e..80f5c40a2c 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 hosts only usage and is skipped), or the `text-delta` stream accumulated before the activity was cut short — a partial answer survives cancel and error paths. `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..b34421dbbf 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/src/run.ts b/packages/subagent/subagent-dsh-sdk/src/run.ts index b8a01ea383..39bf53fffb 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 { assistantMessageOutput, 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,17 +163,21 @@ 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). + // The child's final answer, folded incrementally under the seam's canonical + // rule (`finalAssistantOutput`): the last NON-EMPTY complete assistant + // message when one exists, else the text streamed so far (a partial answer + // surviving cancel). An empty-content message hosts only usage (a max-tokens + // step that assembled no text blocks), so it never erases streamed text. let lastMessage: ContentBlock[] | undefined const partial: string[] = [] 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') { + const content = assistantMessageOutput(event) + if (content !== undefined) { + lastMessage = content + } else 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[] => { 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..c00dfe5fec 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 @@ -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-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index a6a82fb47f..ed5606cbc9 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: 2e2a3873843467b0811ccbc0ed1d9bb6a83eb31f +README.zh.md: 91013164e762bb01e7ad5a51597c6fa559c8d7a3 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 67f0cf5dd1..2e2a387384 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,7 +14,7 @@ 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 hosts only usage and is skipped), else the text it streamed before cancel or truncation cut the turn short — 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. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 648a160be5..91013164e7 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 从完整的自有子运行中读取子 agent 自身最后一条 assistant 消息和最终持久化的轮次原因,并排除任何 fork 初始内容。 +5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条**非空** assistant 消息(空内容消息仅承载 usage,会被跳过),否则取轮次被取消或截断前已流式的文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/subagent/subagent-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index acb4e4d36e..d621674a6e 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -20,6 +20,7 @@ import { applyChildComposition, assertSubagentMaxDepth, childSessionMeta, + finalAssistantOutput, resolveChildAgentOptions, resolveChildDepth, } from '@deepseek-ai/dsh-subagent' @@ -218,9 +219,11 @@ 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 ?? [] + // Canonical selection (`finalAssistantOutput`): the last non-empty assistant + // message, else the text streamed before cancel/error/truncation cut the + // turn short — an empty usage-only message never erases real output. + 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/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index d175322381..471fc46bdd 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -1,4 +1,4 @@ -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 { type Agent, type AgentOptions } from '@deepseek-ai/dsh-agent' @@ -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,33 @@ describe('startInProcessRun', () => { await run.dispose() }) + it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => { + // Step 1 streams "partial one" plus a tool call; step 2 hits max-tokens + // having assembled only a tool-call block, so the loop appends an EMPTY + // assistant/message to host usage. The empty message is not assistant + // output and must not erase step 1's text from the run's 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 +306,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/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 1241aa8042..2b47a91a4b 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: 762030629c09305c48adebc71244655a5faa6585 -README.zh.md: 535cc25895e04e82b6667e6d2769f2dcbfa49cff +README.md: d2d5356fd82a47ecf5cd6b633e5338dde7047901 +README.zh.md: 2fdc3ae6e8376ef7c7aaf11c8e85dd909ef92d2c diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 762030629c..d2d5356fd8 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -56,7 +56,7 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th `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` edge's `lastAssistantMessage` share one selection rule, implemented by the exported `finalAssistantOutput` helper: the child's last non-empty assistant message, else the text it streamed before the turn was cut short ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the 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. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 535cc25895..2fdc3ae6e8 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -56,7 +56,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `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` 共用同一条选取规则,由导出的 `finalAssistantOutput` 辅助函数实现:取子 agent 最后一条非空 assistant 消息,否则取轮次被截断前已流式的文本(契约归 [`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 会话,其一次性运行不会进入基于追踪的枚举结果。 diff --git a/packages/subagent/subagent/src/assistant-output.ts b/packages/subagent/subagent/src/assistant-output.ts new file mode 100644 index 0000000000..5fea60fa89 --- /dev/null +++ b/packages/subagent/subagent/src/assistant-output.ts @@ -0,0 +1,51 @@ +/** + * Canonical selection of a child's final assistant output from its session + * events. Every surface that reports "the child's answer" — backend run + * results and `subagent/end.lastAssistantMessage` — applies this one rule so + * observers agree: the last NON-EMPTY assistant message wins; an empty-content + * message hosts only usage (the loop appends one when a max-tokens step + * assembled no executable blocks) and never erases real output; without any + * non-empty message, the text streamed so far is the answer (a partial + * surviving cancel, error, and truncation paths). + * + * @module @deepseek-ai/dsh-subagent/assistant-output + */ + +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' + +/** + * The content one event contributes as a candidate final answer: an + * `assistant/message` with non-empty content. An empty-content message hosts + * only usage and contributes none. + * @param event - any session event. + * @returns the message content, or `undefined` when this event is not a + * non-empty assistant message. + */ +export function assistantMessageOutput(event: SessionEvent): ContentBlock[] | undefined { + if (event.type !== 'assistant/message') return undefined + const content = event.data.message.content + return content.length > 0 ? content : undefined +} + +/** + * Select the final assistant output from one child-owned event suffix: the + * last non-empty assistant message, else the accumulated `text-delta` stream. + * @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 { + let message: ContentBlock[] | undefined + const partial: string[] = [] + for (const event of events) { + const content = assistantMessageOutput(event) + if (content !== undefined) { + message = content + } else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { + partial.push(event.data.chunk.text) + } + } + if (message !== undefined) return message + const text = partial.join('') + return text.length > 0 ? [{ type: 'text', text }] : undefined +} diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index eddcf63c3c..d3e26e6dbc 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -69,6 +69,7 @@ import { snapshotSubagentDescriptor } from './descriptor.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' export * from './out-of-process.ts' +export { assistantMessageOutput, finalAssistantOutput } from './assistant-output.ts' export { SubagentRunId } from './types.ts' export type { ContinuableCreateRequest, diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 65c61ae9eb..26fdd54256 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -20,6 +20,7 @@ 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' @@ -173,7 +174,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 +221,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/types.ts b/packages/subagent/subagent/src/types.ts index b1df485644..881d63980d 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: the content of the last NON-EMPTY + * assistant message (an empty-content message hosts only usage and is + * skipped), else the text streamed before the turn was cut short, or `[]` + * when the child produced none. + */ readonly output: ContentBlock[] /** * The structured result after a requested `outputSchema` was successfully 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..5219209249 --- /dev/null +++ b/packages/subagent/subagent/tests/assistant-output.spec.ts @@ -0,0 +1,60 @@ +import { describe, expect, it } from 'vitest' +import type { ContentBlock } from '@deepseek-ai/dsh-llm' +import type { SessionEvent } from '@deepseek-ai/dsh-session' +import { assistantMessageOutput, 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 +} + +describe('assistantMessageOutput', () => { + it('returns content only for a non-empty assistant message', () => { + const content: ContentBlock[] = [{ type: 'text', text: 'answer' }] + expect(assistantMessageOutput(message(content))).toBe(content) + expect(assistantMessageOutput(message([]))).toBeUndefined() + expect(assistantMessageOutput(textDelta('chunk'))).toBeUndefined() + }) +}) + +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 the streamed text', () => { + const events = [ + textDelta('streamed '), + textDelta('text'), + message([{ type: 'text', text: 'complete answer' }]), + ] + expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }]) + }) + + it('falls back to accumulated text deltas when no non-empty message exists', () => { + const events = [ + reasoningDelta('thinking'), + textDelta('partial '), + 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() + }) +}) diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index 9370676f76..b60ae57f99 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -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, @@ -1200,6 +1200,45 @@ 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 () => { + // Step 1 streams text plus a tool call; step 2 hits max-tokens having + // assembled only a tool-call block, so the loop appends an EMPTY + // assistant/message to host usage. The terminal edge reports the epoch's + // real answer text, not the internal usage marker. + 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)) From 15d452d7ea2512e3d7a4f4569f820e4cc8482354 Mon Sep 17 00:00:00 2001 From: Yichen Jiang Date: Mon, 10 Aug 2026 13:37:18 +0800 Subject: [PATCH 006/113] fix(tasks): correct the change-feed contract and its documentation MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review found the `onTasksChanged` teardown reasoning inverted. The comment claimed every registration is an effect on the registry's own fiber, so listeners would be gone before service disposal empties the store — but the traceable proxy rebinds `this.ctx` to the CALLER, which this package's own HMR-safety test already proves. The only shipped consumer registers from the api-proxy mux stream, so it was still listening and simply kept the rows it last received. Service disposal now announces the emptied set, and teardown announces its stopping transition immediately instead of leaving an observer on `running` for however long a slow producer takes to release. Two documentation claims were false in the opposite direction: the Agent Note and the ui-task README both said an unowned task is invisible in the header, while `list(caller)` returns unowned tasks to every caller, the carrier fans their changes out to every subscribed session, and this PR's own test asserts exactly that. The note even contradicted itself two sections earlier. Both sides now state the real asymmetries — another session's tasks, and the process-local registry emptying on restart. The "no Web path calls the consuming `ctx.tasks.read()`" invariant claimed a test that did not exist; the carrier suite's producer had no `readOutput` at all, so a stray read would have failed nothing. Its producer now counts cursor consumption and the lifecycle and baseline paths both assert zero. Also: a session created after the mux opened now receives the task baseline it missed, the popover samples its clock when it opens rather than at mount, and a failed task's unbounded producer detail elides instead of widening the row. --- ...8-08-web-background-task-display.i18n.yaml | 4 +- .../2026-08-08-web-background-task-display.md | 2 +- ...26-08-08-web-background-task-display.zh.md | 2 +- apps/web/tests/background-task-list.e2e.ts | 5 +- docs/subsystems/tasks.i18n.yaml | 4 +- docs/subsystems/tasks.md | 10 +++- docs/subsystems/tasks.zh.md | 10 +++- packages/client/ui-task/README.i18n.yaml | 4 +- packages/client/ui-task/README.md | 2 +- packages/client/ui-task/README.zh.md | 2 +- .../src/client/TaskListAction.module.css | 9 +++ .../ui-task/src/client/TaskListAction.tsx | 11 +++- packages/host/apiproxy/src/api-proxy.ts | 8 +++ .../apiproxy/tests/api-proxy-tasks.spec.ts | 59 ++++++++++++++++++- .../tool-cordis/src/api-catalog.ts | 2 +- packages/tasks/tasks-local/src/index.ts | 15 +++-- .../tasks/tasks-local/tests/tasks.spec.ts | 54 +++++++++++++++++ packages/tasks/tasks/README.i18n.yaml | 4 +- packages/tasks/tasks/README.md | 2 +- packages/tasks/tasks/README.zh.md | 2 +- packages/tasks/tasks/src/index.ts | 10 +++- 21 files changed, 192 insertions(+), 29 deletions(-) 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 index f00211b5f4..9984adfa0f 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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: 17ac109082ab3b2afea92194d0d148cf11ba9d0a -2026-08-08-web-background-task-display.zh.md: 1049218337caae3bd95c6f6ff3183589d0ce9d15 +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 index 17ac109082..558d0e26b3 100644 --- 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 @@ -125,7 +125,7 @@ Below it, [`tasks-local`](../../../../packages/tasks/tasks-local/tests/tasks.spe **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.** An unowned task is invisible in the header while `task_list` still reports it to the model. In practice every tool call carries an agent, so this stays theoretical, but the two surfaces are not interchangeable. +**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. 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 index 1049218337..9c9f4643bb 100644 --- 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 @@ -125,7 +125,7 @@ abstract onTasksChanged(listener: TasksChangedListener): () => void **无主任务的扇出很容易做漏。** 只推给变更 owner 所在的会话,对有主任务是对的,对处处可见的无主任务则是悄悄错的。这个 bug 只会在会创建无主任务的组合里显形,所以载体套件直接覆盖了它。 -**UI 的集合不等于注册表的集合。** 无主任务在 header 里不可见,而 `task_list` 仍会把它报告给模型。实践中每次工具调用都带着 agent,所以这停留在理论层面,但两个界面并不可互换。 +**UI 的集合不等于注册表的集合。** header 显示的是「一个会话能看到什么」,所以别的会话拥有的任务在这里永远不出现,尽管注册表里有它;而由于注册表是进程本地的,一次重启会清空所有列表,transcript 里那些启动它们的 `run_in_background` 卡片却还在。无主任务是反过来的情形:它们会进入每一个会话的列表,正如 `list(caller)` 对每个调用方都报告它们。 **终态行会堆积。** 注册表把已结算任务留到 owner 销毁,所以一个跑了很多后台命令的长会话会积出长列表。如果真的成为抱怨,给终态尾巴加上限是呈现层改动而非协议改动。 diff --git a/apps/web/tests/background-task-list.e2e.ts b/apps/web/tests/background-task-list.e2e.ts index 217eda49fa..734d43131a 100644 --- a/apps/web/tests/background-task-list.e2e.ts +++ b/apps/web/tests/background-task-list.e2e.ts @@ -82,8 +82,11 @@ describe.skipIf(MODE === 'record')('web e2e: background task list', () => { 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' }) - await expect.poll(() => trigger.count(), { timeout: 5_000 }).toBe(0) + expect(await trigger.count()).toBe(0) const started = await scaffold.ctx.tools.execute({ signal: new AbortController().signal, diff --git a/docs/subsystems/tasks.i18n.yaml b/docs/subsystems/tasks.i18n.yaml index bd83fc1895..e6444d9b1a 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: fd4041698de2fd19156c2a87d378d7fafdd1d15a -tasks.zh.md: 00c8aab2794f99678e9707d2dfb7c67bf411b24d +tasks.md: 2ee062e1c410f7a26af273ca9adc70a978f25563 +tasks.zh.md: 9b6ea8260e439eca2ffffa3b50fb030f117c495c diff --git a/docs/subsystems/tasks.md b/docs/subsystems/tasks.md index fd4041698d..2ee062e1c4 100644 --- a/docs/subsystems/tasks.md +++ b/docs/subsystems/tasks.md @@ -248,8 +248,14 @@ 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, the stopping transition, settlement, and owner-disposal - * removal — so an observer re-reads rather than accumulating deltas. + * 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. + * + * The registration binds to the CALLING fiber, so an observer mounted outside + * this service still receives the disposal emptying; that is what stops a + * consumer from retaining rows after the registry unloads. * * This is not a superset of {@link onTaskDone}: that one delivers the terminal * record under first-wins semantics a control surface couples to notice diff --git a/docs/subsystems/tasks.zh.md b/docs/subsystems/tasks.zh.md index 00c8aab279..9b6ea8260e 100644 --- a/docs/subsystems/tasks.zh.md +++ b/docs/subsystems/tasks.zh.md @@ -248,8 +248,14 @@ 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, the stopping transition, settlement, and owner-disposal - * removal — so an observer re-reads rather than accumulating deltas. + * 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. + * + * The registration binds to the CALLING fiber, so an observer mounted outside + * this service still receives the disposal emptying; that is what stops a + * consumer from retaining rows after the registry unloads. * * This is not a superset of {@link onTaskDone}: that one delivers the terminal * record under first-wins semantics a control surface couples to notice diff --git a/packages/client/ui-task/README.i18n.yaml b/packages/client/ui-task/README.i18n.yaml index 45157f12e5..39f800e04b 100644 --- a/packages/client/ui-task/README.i18n.yaml +++ b/packages/client/ui-task/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-task/README.md -README.md: 318984808838cb7a336a4c34c95a2f27db578423 -README.zh.md: 7adeb254662fd23cf4bd1efc8618471cf74df7c0 +README.md: a1430db55c7519c612e5d39de4627c750976d2d5 +README.zh.md: 5e29c939806f9a6500e78324c5a3322b6fd94539 diff --git a/packages/client/ui-task/README.md b/packages/client/ui-task/README.md index 3189848088..a1430db55c 100644 --- a/packages/client/ui-task/README.md +++ b/packages/client/ui-task/README.md @@ -21,4 +21,4 @@ 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 the owning session can see through the wire view, so an unowned task (one started without a live `Agent`) is invisible here while `task_list` still reports it to the model. +- **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 index 7adeb25466..5e29c93980 100644 --- a/packages/client/ui-task/README.zh.md +++ b/packages/client/ui-task/README.zh.md @@ -21,4 +21,4 @@ Escape 关闭列表并把焦点交还触发器,在其外部按下指针同理 ## Known Limitations and Deferred Work - **行是只读的** —— 任务的流式输出与人类发起的中断是各自独立的阶段。中断还额外欠一个 seam 目前没有回答的、面向模型的决策:`kill()` 会把终态投递标为已上报,所以照当前契约写出来的中断会让模型一直以为它的任务还在跑。 -- **列表不等于注册表自己的集合** —— 它展示的是拥有它的会话通过线路视图能看到的东西,因此一个无主任务(在没有活体 `Agent` 时启动的任务)在这里不可见,而 `task_list` 仍会把它报告给模型。 +- **列表不等于注册表自己的集合** —— 它展示的是「一个会话通过线路视图能看到什么」,所以别的会话拥有的任务在这里永远不出现;而进程重启会清空列表,transcript 里启动这些任务的 `run_in_background` 卡片却还在。无主任务(在没有活体 `Agent` 时启动的)是反过来的情形:它会进入每一个会话的列表,与 `list(caller)` 对每个调用方的报告一致。 diff --git a/packages/client/ui-task/src/client/TaskListAction.module.css b/packages/client/ui-task/src/client/TaskListAction.module.css index 6bf6ed2507..b6ac05aa31 100644 --- a/packages/client/ui-task/src/client/TaskListAction.module.css +++ b/packages/client/ui-task/src/client/TaskListAction.module.css @@ -111,6 +111,15 @@ 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 index 722d317aa9..ede2eca377 100644 --- a/packages/client/ui-task/src/client/TaskListAction.tsx +++ b/packages/client/ui-task/src/client/TaskListAction.tsx @@ -148,7 +148,14 @@ export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProp className={css.trigger} aria-expanded={open} aria-label={countLabel} - onClick={() => { setOpen(current => !current) }} + onClick={() => { + // Sample the clock in the same commit that opens the list: the + // mount-time value predates every task, so the first painted frame + // would otherwise clamp a long-running row to zero until the + // open effect corrects it a frame later. + setNow(Date.now()) + setOpen(current => !current) + }} > {liveCount > 0 ? : null} {countLabel} @@ -167,7 +174,7 @@ export function TaskListAction({ sessionId, useSessions, t }: TaskListActionProp {task.kind} {task.label} - {task.detail ?? status} + {task.detail ?? status} { 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) diff --git a/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts index 1af65962ca..ebfe44d375 100644 --- a/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts +++ b/packages/host/apiproxy/tests/api-proxy-tasks.spec.ts @@ -29,15 +29,19 @@ type TaskFrame = Extract */ 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, settle: (outcome: TaskOutcome) => { settle(outcome) } } + return { spec, reads, settle: (outcome: TaskOutcome) => { settle(outcome) } } } async function harness(withRegistry: boolean): Promise<{ ctx: Context; session: Session; agent: Agent }> { @@ -204,3 +208,56 @@ describe('session/tasks without the registry', () => { 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/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 8fa9d37599..c8c217c5bd 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -1126,7 +1126,7 @@ export const SERVICE_API: readonly ServiceApiEntry[] = [ }, { signature: 'abstract onTasksChanged(listener: TasksChangedListener): () => void', - jsDoc: '/**\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, the stopping transition, settlement, and owner-disposal\n * removal — so an observer re-reads rather than accumulating deltas.\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 */', + jsDoc: '/**\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 * The registration binds to the CALLING fiber, so an observer mounted outside\n * this service still receives the disposal emptying; that is what stops a\n * consumer from retaining rows after the registry unloads.\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', diff --git a/packages/tasks/tasks-local/src/index.ts b/packages/tasks/tasks-local/src/index.ts index 947ddec004..5b52d90539 100644 --- a/packages/tasks/tasks-local/src/index.ts +++ b/packages/tasks/tasks-local/src/index.ts @@ -369,11 +369,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. `onTasksChanged` binds to + // the CALLING fiber (the traceable proxy rebinds `this.ctx`), so a consumer + // mounted outside this service — the api-proxy carrier reads `ctx.get` from + // the mux stream — is still listening 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() - // No change notification here: every `onTasksChanged` registration is an - // effect on this service's own fiber, so the listeners are already gone by - // the time service teardown reaches this line. An observer learns the - // registry left through its own disposal, not through a final empty set. + for (const owner of emptied) this.notifyChanged(owner) this.changeListeners.clear() // Detach cross-fiber owner effects after the shared store is quiescent. const ownerCleanups = [...this.ownerCleanups.values()] @@ -392,6 +395,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/tests/tasks.spec.ts b/packages/tasks/tasks-local/tests/tasks.spec.ts index ebd1e73b52..1fa0323fec 100644 --- a/packages/tasks/tasks-local/tests/tasks.spec.ts +++ b/packages/tasks/tasks-local/tests/tasks.spec.ts @@ -853,3 +853,57 @@ describe('LocalTaskService.onTasksChanged', () => { 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 da255745ad..628874e038 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: 8ad124531daf74f1d44ff46aaaad9b9b860b07c8 -README.zh.md: c1452d4610c65fa66c5ba2f2939b8c501e92bd7a +README.md: 71da4cbd189c3cd2aac51bfb004d2dd774e081c2 +README.zh.md: 283e3dd3aee1c86c856f056156ebe12caff276ae diff --git a/packages/tasks/tasks/README.md b/packages/tasks/tasks/README.md index 8ad124531d..71da4cbd18 100644 --- a/packages/tasks/tasks/README.md +++ b/packages/tasks/tasks/README.md @@ -12,7 +12,7 @@ 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, the stopping transition, settlement, and owner-disposal removal — 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. +- `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 none is attached. 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 c1452d4610..283e3dd3ae 100644 --- a/packages/tasks/tasks/README.zh.md +++ b/packages/tasks/tasks/README.zh.md @@ -12,7 +12,7 @@ - `kill(id, caller?, reason?)` 在更改状态前调用生产方取消。取消抛出异常时任务保持运行;成功则把状态改为 `stopping`,并将终止交付标记为已报告。 - `wait(id, timeoutMs, caller?, signal?)` 返回终止快照,或在超时时返回存活快照。中止只会停止等待;一旦终止交付已向该等待方提交,终止结果优先。 - `onTaskDone(listener)` 观察每条终止记录及其精确 owner。监听器抛出的异常和产生的拒绝都会被隔离;系统不会等待监听器工作。 -- `onTasksChanged(listener)` 观察可见集合的变化——注册、转入 stopping、结算,以及 owner 销毁时的移除——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。 +- `onTasksChanged(listener)` 观察可见集合的变化——注册、每一次转入 stopping(包括 teardown 在等待缓慢生产者之前的那一次)、结算、owner 销毁时的移除,以及服务销毁提交的清空——只携带集合发生变化的那个 owner,或在无主任务变化、因而每个调用方的集合都随之变化时携带 `undefined`。它按 owner 分粒度,因为移除是任何逐任务记录都无法表达的变化;它也不是 `onTaskDone` 的超集:它不含任何投递含义,也不把任何东西标为已上报。注册绑定的是调用方 fiber,因此挂在注册表之外的观察者仍能收到销毁时的清空。 - `attachSurface(name)` 在其 effect 生命周期内声明控制表层。如果没有附加任何表层,`start()` 会在生产方执行前失败。 有 owner 的访问会比较任务的 `SessionId` 与调用方。`bash-1` 等 id 可预测,因此这道隔离是安全边界。无 owner 的任务向调用方开放,并持续到服务释放。 diff --git a/packages/tasks/tasks/src/index.ts b/packages/tasks/tasks/src/index.ts index 600f157660..1abc750564 100644 --- a/packages/tasks/tasks/src/index.ts +++ b/packages/tasks/tasks/src/index.ts @@ -134,8 +134,14 @@ export abstract class TaskService extends Service { /** * Register an effect-scoped observer of visible-set changes. It fires after * every commit that changes what {@link list} returns for that owner — - * registration, the stopping transition, settlement, and owner-disposal - * removal — so an observer re-reads rather than accumulating deltas. + * 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. + * + * The registration binds to the CALLING fiber, so an observer mounted outside + * this service still receives the disposal emptying; that is what stops a + * consumer from retaining rows after the registry unloads. * * This is not a superset of {@link onTaskDone}: that one delivers the terminal * record under first-wins semantics a control surface couples to notice From 1861a3fc7ce3b26e6ddcf283efbae9b9957c10cf Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 15:09:07 +0800 Subject: [PATCH 007/113] feat(fs): add a minimal read_image tool over the attachment and fs seams The model reads a PNG/JPEG/WebP/GIF file, the bytes commit through the durable attachment lifecycle, and the tool result carries the real ImageBlock so the image enters context from the next request onward. FileSystem gains a bounded readBytes primitive (local + E2B providers); registration is conditional on the attachment store, and a strict execution gate refuses routes that do not declare image input, so a text route's durable history stays free of image blocks. llm-replay models may declare inputModalities, letting keyless ACP snapshots pin both the sha256-referenced success and the verbatim refusal. Supersedes the withdrawn route-scoped design of PR #598; the decision record is .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md. --- ...26-08-10-minimal-read-image-tool.i18n.yaml | 6 + .../2026-08-10-minimal-read-image-tool.md | 33 ++ .../2026-08-10-minimal-read-image-tool.zh.md | 33 ++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 10 +- docs/config-catalog.zh.md | 8 +- docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- docs/subsystems/filesystem.i18n.yaml | 4 +- docs/subsystems/filesystem.md | 13 + docs/subsystems/filesystem.zh.md | 13 + docs/tool-catalog.i18n.yaml | 4 +- docs/tool-catalog.md | 25 +- docs/tool-catalog.zh.md | 25 +- .../image-text-route.cordis.snapshot.yml | 39 ++ .../acp-agent/image-text-route.cordis.yml | 27 + examples/acp-agent/image.cordis.snapshot.yml | 41 ++ examples/acp-agent/image.cordis.yml | 27 + examples/acp-agent/tests/acp.snapshot.ts | 26 + .../read-image-text-route/input.json | 14 + .../read-image-text-route/session.jsonl | 26 + .../stdout.expected.jsonl | 4 + .../read-image-text-route/workspace/red.png | Bin 0 -> 69 bytes .../tests/snapshots/read-image/input.json | 14 + .../tests/snapshots/read-image/session.jsonl | 26 + .../read-image/stdout.expected.jsonl | 4 + .../read-image/tool-schemas.expected.json | 543 ++++++++++++++++++ .../snapshots/read-image/workspace/red.png | Bin 0 -> 69 bytes examples/package.json | 7 +- .../tests/workspace-context.spec.ts | 4 + .../core/tools/tests/gen-tool-catalog.spec.ts | 2 +- packages/e2b/fs-e2b/src/index.ts | 18 + packages/e2b/fs-e2b/tests/filesystem.spec.ts | 17 + packages/fs/fs-local/README.i18n.yaml | 4 +- packages/fs/fs-local/README.md | 3 +- packages/fs/fs-local/README.zh.md | 3 +- packages/fs/fs-local/src/fsio.ts | 46 ++ packages/fs/fs-local/src/index.ts | 5 + packages/fs/fs-local/tests/filesystem.spec.ts | 37 ++ packages/fs/fs/README.i18n.yaml | 4 +- packages/fs/fs/README.md | 9 +- packages/fs/fs/README.zh.md | 9 +- packages/fs/fs/src/index.ts | 12 + packages/fs/fs/src/types.ts | 1 + packages/fs/fs/tests/service.spec.ts | 17 + packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 38 +- packages/fs/tool-fs/README.zh.md | 38 +- packages/fs/tool-fs/package.json | 2 + packages/fs/tool-fs/src/index.ts | 7 + packages/fs/tool-fs/src/read-image.ts | 231 ++++++++ packages/fs/tool-fs/tests/read-image.spec.ts | 462 +++++++++++++++ packages/fs/tool-fs/tests/tools.spec.ts | 7 + packages/fs/tool-fs/tsconfig.json | 3 + .../tool-cordis/src/api-catalog.ts | 4 + .../skill-local/tests/skill-local.spec.ts | 4 + packages/support/llm-replay/README.i18n.yaml | 4 +- packages/support/llm-replay/README.md | 2 +- packages/support/llm-replay/README.zh.md | 2 +- packages/support/llm-replay/src/index.ts | 7 + .../llm-replay/tests/llm-replay.spec.ts | 6 +- pnpm-lock.yaml | 6 + scripts/gen-cordis-catalog.ts | 1 + scripts/gen-tool-catalog.ts | 35 +- 65 files changed, 1973 insertions(+), 67 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-minimal-read-image-tool.zh.md create mode 100644 examples/acp-agent/image-text-route.cordis.snapshot.yml create mode 100644 examples/acp-agent/image-text-route.cordis.yml create mode 100644 examples/acp-agent/image.cordis.snapshot.yml create mode 100644 examples/acp-agent/image.cordis.yml create mode 100644 examples/acp-agent/tests/snapshots/read-image-text-route/input.json create mode 100644 examples/acp-agent/tests/snapshots/read-image-text-route/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image-text-route/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image-text-route/workspace/red.png create mode 100644 examples/acp-agent/tests/snapshots/read-image/input.json create mode 100644 examples/acp-agent/tests/snapshots/read-image/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image/stdout.expected.jsonl create mode 100644 examples/acp-agent/tests/snapshots/read-image/tool-schemas.expected.json create mode 100644 examples/acp-agent/tests/snapshots/read-image/workspace/red.png create mode 100644 packages/fs/tool-fs/src/read-image.ts create mode 100644 packages/fs/tool-fs/tests/read-image.spec.ts 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/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index ae1ec3bb37..340901034d 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: 1140b0bec68c0a883ec5020cb204bead9739bbf2 -config-catalog.zh.md: 220d66cadda37d388b759a1ab9c0cc3691a55ec9 +config-catalog.md: 7cfc76ecd7a71f30ea3725fbc442658b14c20c4a +config-catalog.zh.md: fb5c360364a2d4d22ea0d1582f51473db159d059 diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 1140b0bec6..7cfc76ecd7 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -528,7 +528,7 @@ export interface Config { } ``` -Source: [`packages/fs/fs-local/src/index.ts:39`](../packages/fs/fs-local/src/index.ts) +Source: [`packages/fs/fs-local/src/index.ts:40`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-fs-sandbox` @@ -983,12 +983,14 @@ 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[] } ``` -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:744`](../packages/support/llm-replay/src/index.ts) +Source: [`packages/support/llm-replay/src/index.ts:751`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` @@ -2079,7 +2081,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` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index 220d66cadd..fb5c360364 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -530,7 +530,7 @@ export interface Config { } ``` -来源:[`packages/fs/fs-local/src/index.ts:39`](../packages/fs/fs-local/src/index.ts) +来源:[`packages/fs/fs-local/src/index.ts:40`](../packages/fs/fs-local/src/index.ts) ## `@deepseek-ai/dsh-fs-sandbox` @@ -985,12 +985,14 @@ 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[] } ``` -依赖:[`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:744`](../packages/support/llm-replay/src/index.ts) +来源:[`packages/support/llm-replay/src/index.ts:751`](../packages/support/llm-replay/src/index.ts) ## `@deepseek-ai/dsh-llm-retry` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index caf3a7b7e2..4eef7e146d 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: e75b98de7faf74388fdf95f0fe78794cda4a91fe -module-graph.zh.md: e3e01c8e63f35db422f5bf0e669ec2024fff304b +module-graph.md: 13cc178f99533e47e29ac5764833ffd2fc2a18db +module-graph.zh.md: 6754581ede7e9c230f916870e20f9054a9fa3d5b diff --git a/docs/module-graph.md b/docs/module-graph.md index e75b98de7f..13cc178f99 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -775,6 +775,7 @@ flowchart TD pkg_bash_env --> pkg_paths pkg_bash_env --> pkg_session_persistence pkg_bash_env --> pkg_tools + pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -1361,7 +1362,7 @@ flowchart TD | [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index e3e01c8e63..6754581ede 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -777,6 +777,7 @@ flowchart TD pkg_bash_env --> pkg_paths pkg_bash_env --> pkg_session_persistence pkg_bash_env --> pkg_tools + pkg_tool_fs --> pkg_attachment pkg_tool_fs --> pkg_fs pkg_tool_fs --> pkg_invariants pkg_tool_fs --> pkg_llm @@ -1363,7 +1364,7 @@ flowchart TD | [`agent-tool-mode`](../packages/core/agent-tool-mode) | `core` | [`invariants`](../packages/support/invariants), [`tools`](../packages/core/tools) | | [`tool-goal`](../packages/goal/tool-goal) | `goal` | [`agent`](../packages/core/agent), [`goal`](../packages/goal/goal), [`invariants`](../packages/support/invariants), [`llm`](../packages/llm/llm), [`session`](../packages/core/session), [`system-prompt`](../packages/core/system-prompt), [`tools`](../packages/core/tools) | | [`bash-env`](../packages/bash/bash-env) | `bash` | [`bash`](../packages/bash/bash), [`invariants`](../packages/support/invariants), [`paths`](../packages/util/paths), [`session-persistence`](../packages/session/session-persistence), [`tools`](../packages/core/tools) | -| [`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) | diff --git a/docs/subsystems/filesystem.i18n.yaml b/docs/subsystems/filesystem.i18n.yaml index fac61bde63..ba3fb6390b 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: e0edad514b0c4d9108b18cc0b472600b29024c51 -filesystem.zh.md: 81c2d87b6a96740c7a99449a27c16331f5a0645a +filesystem.md: 2013d2f2c9be4ac2cc16c1b0cf3685e961919a05 +filesystem.zh.md: 86e8cf3f2cf8c77ab4b53a438d86a980e13f4ac5 diff --git a/docs/subsystems/filesystem.md b/docs/subsystems/filesystem.md index e0edad514b..2013d2f2c9 100644 --- a/docs/subsystems/filesystem.md +++ b/docs/subsystems/filesystem.md @@ -255,6 +255,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' @@ -372,6 +373,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 81c2d87b6a..86e8cf3f2c 100644 --- a/docs/subsystems/filesystem.zh.md +++ b/docs/subsystems/filesystem.zh.md @@ -255,6 +255,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' @@ -372,6 +373,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/tool-catalog.i18n.yaml b/docs/tool-catalog.i18n.yaml index 3e74bbc3e6..888d47ff52 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: 58267e208d919e7fa817991207a44ac0864fa00e -tool-catalog.zh.md: 1eda3506644bae6da56895c366a56b566536ed62 +tool-catalog.md: af8167569e2ac4cc24f0d80c8c0c8a1d09f2237a +tool-catalog.zh.md: 053a0e1e4a0db0563f5fb0e1b34b735fdb9832ad diff --git a/docs/tool-catalog.md b/docs/tool-catalog.md index 58267e208d..af8167569e 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. | @@ -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 1eda350664..053a0e1e4a 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。 | @@ -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/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..af7a8614ed --- /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: '@cordisjs/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..96ef510ab3 --- /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: '@cordisjs/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..e4f8aecdb0 --- /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: '@cordisjs/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..d306afe735 --- /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: '@cordisjs/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/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index af044fc58f..fcce9bd99d 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)) @@ -153,6 +155,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, 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 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001 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 0000000000000000000000000000000000000000..62a5f8f47fec02344e5bf9061888262f677cf5d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Ar*6yf1E$Sz`)GN$Z+!C Rr1wB^22WQ%mvv4FO#seq5RCu; literal 0 HcmV?d00001 diff --git a/examples/package.json b/examples/package.json index 48ac39f5ca..dce26b87da 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", @@ -14,6 +14,7 @@ "@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:*", @@ -77,8 +78,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/packages/context/workspace-context/tests/workspace-context.spec.ts b/packages/context/workspace-context/tests/workspace-context.spec.ts index 71073264cd..750e692dd5 100644 --- a/packages/context/workspace-context/tests/workspace-context.spec.ts +++ b/packages/context/workspace-context/tests/workspace-context.spec.ts @@ -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/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/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index b26338ffa4..e8921909d4 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -227,6 +227,24 @@ export class E2BFileSystem extends FileSystem { } } + override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise { + const sandbox = await this.ctx.e2b.getSandbox() + await this.requireRegular(target, signal) + let bytes: Uint8Array + try { + // The E2B files API returns the whole object; the remote sandbox owns + // that buffering, so the seam bound is enforced on the complete result. + bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) }) + } catch (error: unknown) { + throw mapError(error, 'read', target.displayPath, signal) + } + assertNotAborted(signal, 'read') + if (bytes.byteLength > maxBytes) { + throw new FsError(`cannot read "${target.displayPath}": ${bytes.byteLength} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') + } + return bytes + } + override async streamText(target: FsTarget, signal?: AbortSignal): Promise> { const sandbox = await this.ctx.e2b.getSandbox() await this.requireRegular(target, signal) diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 6ee3df6543..44049b7972 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -471,6 +471,23 @@ 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]) + await expectCode(fs.readBytes(target, undefined, 3), 'FS_TOO_LARGE') + 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('honors aborts before and during remote reads', async () => { const remote = new FakeRemote() remote.file('/workspace/a', 'a') diff --git a/packages/fs/fs-local/README.i18n.yaml b/packages/fs/fs-local/README.i18n.yaml index ef76470394..b5fbf869ab 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: 7b993fa123d13833313ecf3f78c64e466b960d5d -README.zh.md: 428719137f988395b76513eab2c3f76ce4f331e5 +README.md: 4b65bc3c5a5b31a8b4f96ec86e2239c2da680897 +README.zh.md: 45d9646f89623d2de106cd8e1dab38941c912977 diff --git a/packages/fs/fs-local/README.md b/packages/fs/fs-local/README.md index 7b993fa123..4b65bc3c5a 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`). - **`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 428719137f..45d9646f89 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`)。 - **`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/src/fsio.ts b/packages/fs/fs-local/src/fsio.ts index 24ad9b929f..23bd5215db 100644 --- a/packages/fs/fs-local/src/fsio.ts +++ b/packages/fs/fs-local/src/fsio.ts @@ -97,6 +97,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. */ @@ -379,6 +381,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 9cd4be41b8..d42f508863 100644 --- a/packages/fs/fs-local/src/index.ts +++ b/packages/fs/fs-local/src/index.ts @@ -27,6 +27,7 @@ import { probeNoFollow, readForEdit, readTextForDiff, + readWholeBytes, readWholeText, resolveLocalTarget, restoreLineEndings, @@ -129,6 +130,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/tests/filesystem.spec.ts b/packages/fs/fs-local/tests/filesystem.spec.ts index f6736ff9c3..930300e390 100644 --- a/packages/fs/fs-local/tests/filesystem.spec.ts +++ b/packages/fs/fs-local/tests/filesystem.spec.ts @@ -238,6 +238,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/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index c13921729a..9eb2da496a 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: 4e3a37b997c9d377ebb3795f61f9070a8a9c38ea +README.zh.md: 50551d3752acf4ffc12ae40c711c9289fd4d87d7 diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 62d3febde8..4e3a37b997 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. | @@ -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..50551d3752 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`。应用和写入以原子方式完成,使用同一个变更临界区。 | @@ -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/src/index.ts b/packages/fs/fs/src/index.ts index 0be94dccc4..44558fa016 100644 --- a/packages/fs/fs/src/index.ts +++ b/packages/fs/fs/src/index.ts @@ -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/types.ts b/packages/fs/fs/src/types.ts index e842d0d574..6f9fc2a4cc 100644 --- a/packages/fs/fs/src/types.ts +++ b/packages/fs/fs/src/types.ts @@ -176,6 +176,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/service.spec.ts b/packages/fs/fs/tests/service.spec.ts index 2340871304..d2c1787aed 100644 --- a/packages/fs/fs/tests/service.spec.ts +++ b/packages/fs/fs/tests/service.spec.ts @@ -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/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index 96785be516..b83cc55426 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: e295ad63902cbae245229fa86b780aeb59de808d +README.zh.md: be87bb18a1c49654d07977b5c8e2577188517077 diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index 27b53aca50..e295ad6390 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`, a bounded `ctx.fs.readBytes` capped at `imageLimits.maxImageBytes`, `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 PNG/JPEG/WebP/GIF format`; 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..be87bb18a1 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`、以 `imageLimits.maxImageBytes` 为上限的有界 `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 缓存影响 + +只追加;新可见内容跟在可复用请求前缀之后,不会使既有 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 PNG/JPEG/WebP/GIF format`;提供方和策略模板在各自包的 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..ceaf64b7ed 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -29,6 +29,7 @@ "schemastery": "^3.18.0" }, "peerDependencies": { + "@deepseek-ai/dsh-attachment": "^0.0.1", "@deepseek-ai/dsh-fs": "^0.0.1", "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", @@ -44,6 +45,7 @@ "@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:^", diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index a4c96d606b..7d4d343972 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -11,6 +11,7 @@ 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' @@ -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/read-image.ts b/packages/fs/tool-fs/src/read-image.ts new file mode 100644 index 0000000000..b12bf52e99 --- /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 '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 { FsError } from '@deepseek-ai/dsh-fs' +import type {} from '@deepseek-ai/dsh-fs' +import { sessionResolveOptions } from './session-cwd.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 displayPath - the path rendered in refusal messages. + */ +export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, displayPath: 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 "${displayPath}" 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 "${displayPath}" 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. Execution gates on the optional + * `attachments`/`llm` services and the calling route's declared image input; + * registration itself is unconditional so denial happens at the operation + * boundary rather than through schema omission. + * @param ctx - the plugin context; registrations are effects scoped to it, and + * 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 = await ctx.fs.resolve(args.file_path, sessionResolveOptions(exec, args.file_path)) + const info = await ctx.fs.stat(target, exec.signal) + if (!info) 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 data = await ctx.fs.readBytes(target, exec.signal, attachments.imageLimits.maxImageBytes) + // 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 PNG/JPEG/WebP/GIF format`, + { 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/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts new file mode 100644 index 0000000000..229069a8d2 --- /dev/null +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -0,0 +1,462 @@ +/** + * 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 '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 } + 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 PNG/JPEG/WebP/GIF format') + }) + + 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('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 missing = await readImage(ctx, { file_path: 'absent.png' }, agentOn('vision-model')) + expect(missing.isError).toBe(true) + expect(text(missing)).toContain('not found') + + 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('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..78289d6c31 100644 --- a/packages/fs/tool-fs/tests/tools.spec.ts +++ b/packages/fs/tool-fs/tests/tools.spec.ts @@ -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/self-modification/tool-cordis/src/api-catalog.ts b/packages/self-modification/tool-cordis/src/api-catalog.ts index 30d3f6a602..a564507447 100644 --- a/packages/self-modification/tool-cordis/src/api-catalog.ts +++ b/packages/self-modification/tool-cordis/src/api-catalog.ts @@ -424,6 +424,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 */', diff --git a/packages/skill/skill-local/tests/skill-local.spec.ts b/packages/skill/skill-local/tests/skill-local.spec.ts index 8bb08d2947..ca547aaab8 100644 --- a/packages/skill/skill-local/tests/skill-local.spec.ts +++ b/packages/skill/skill-local/tests/skill-local.spec.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') diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index 6d048c2325..4cef8ce1c7 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: ade19b66f96ef26119944cbebba23c43e4b2117c +README.zh.md: 4752e03b04be175d7f3e37fc57fe9c74e1f8a303 diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index ae7cb5414a..ade19b66f9 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 declared `inputModalities` (so a scenario can exercise capability gates such as the image-capable `read_image` route check); 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..4752e03b04 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` 和声明的 `inputModalities`(让场景能够触发能力门禁,例如 `read_image` 的图像路由检查);已配置路由通过回放适配器分派,绝不执行提供方 I/O。 | | `paceMs` | number | 无(突发) | 可选的每分片毫秒延迟,使下游传输(例如真实浏览器观察到的 Web SSE(Server-Sent Events)多路复用器)看到真正的增量传递。它只是仿真开关,测试不得依赖它保证正确性。值必须是非负整数;pace 等待期间中止会迅速取消流。 | ```yaml diff --git a/packages/support/llm-replay/src/index.ts b/packages/support/llm-replay/src/index.ts index 9af72bbbec..61b1142383 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -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[] } /** One provider route exposed by the replay adapter. */ @@ -569,6 +572,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] }, }))) } @@ -582,6 +586,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 } }, diff --git a/packages/support/llm-replay/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index f9aa66bce5..0c36dca1ff 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -592,7 +592,7 @@ describe('installLlmReplay (through the real LlmService)', () => { backoff: { initialDelayMs: 1, maxDelayMs: 1, jitterRatio: 0 }, }, models: [ - { id: 'flash', contextWindow: 128_000 }, + { id: 'flash', contextWindow: 128_000, inputModalities: ['text', 'image'] }, { id: 'pro', name: 'Pro', description: 'Larger model' }, ], }, @@ -605,13 +605,15 @@ 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'], }) + await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('inputModalities') await expect(ctx.llm.resolveModelInfo('deepseek', 'pro')).resolves.not.toHaveProperty('context') await expect(ctx.llm.resolveModelInfo('deepseek', 'unlisted')).resolves.not.toHaveProperty('context') await expect(ctx.llm.resolveModelInfo('empty', 'unlisted')).resolves.not.toHaveProperty('context') diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d9561006aa..b8d6544783 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -397,6 +397,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 @@ -3730,6 +3733,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 diff --git a/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index b95adbdec5..40fd8d7d4b 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -460,6 +460,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-tool-catalog.ts b/scripts/gen-tool-catalog.ts index 942a9ec9e0..ed74dbf802 100644 --- a/scripts/gen-tool-catalog.ts +++ b/scripts/gen-tool-catalog.ts @@ -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', From 97a9ec5a0e64401a966fac9ecb1515bb30b84a2d Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 15:35:33 +0800 Subject: [PATCH 008/113] fix(review): bound E2B readBytes at the seam, prove conditional-registration disposal, and align read_image contracts - E2BFileSystem.readBytes now short-circuits on the stat size before any content transfer and streams the remote object, cancelling at the first chunk past the cap, honoring the seam's bounded-buffering contract; the fs-e2b README pair documents the new primitive. - The tool-fs HMR test now proves the attachments-scoped registration: disposing the store withdraws read_image while read/write/edit stay, remounting restores it, and disposing the plugin withdraws everything. - read_image caps reads at the smaller of maxImageBytes and maxMessageImageBytes, records an absent observation for a missing target like its sibling read, widens the mismatch remedy to cover out-of-family formats, and renames the gate's parameter to requestedPath; module/apply/registration JSDoc now match the shipped composition. zh terminology aligned; the examples manifest keeps its literal arrow. --- examples/package.json | 2 +- packages/e2b/fs-e2b/README.i18n.yaml | 4 +- packages/e2b/fs-e2b/README.md | 1 + packages/e2b/fs-e2b/README.zh.md | 1 + packages/e2b/fs-e2b/src/index.ts | 60 ++++++++++++++++---- packages/e2b/fs-e2b/tests/filesystem.spec.ts | 16 ++++++ packages/fs/tool-fs/README.i18n.yaml | 4 +- packages/fs/tool-fs/README.md | 4 +- packages/fs/tool-fs/README.zh.md | 10 ++-- packages/fs/tool-fs/src/index.ts | 4 +- packages/fs/tool-fs/src/read-image.ts | 33 ++++++----- packages/fs/tool-fs/tests/read-image.spec.ts | 41 ++++++++++++- 12 files changed, 141 insertions(+), 39 deletions(-) diff --git a/examples/package.json b/examples/package.json index dce26b87da..a7440e3616 100644 --- a/examples/package.json +++ b/examples/package.json @@ -3,7 +3,7 @@ "private": true, "version": "0.0.1", "type": "module", - "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports\u2192lib. Not a build target.", + "description": "Workspace umbrella for runnable demos and example-owned test compositions: declares their cordis.yml packages so plain Node resolves real exports→lib. Not a build target.", "dependencies": { "@cordisjs/plugin-hmr": "workspace:*", "@cordisjs/plugin-include": "workspace:*", diff --git a/packages/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/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index e8921909d4..75005fa3ad 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -229,20 +229,59 @@ export class E2BFileSystem extends FileSystem { override async readBytes(target: FsTarget, signal: AbortSignal | undefined, maxBytes: number): Promise { const sandbox = await this.ctx.e2b.getSandbox() - await this.requireRegular(target, signal) - let bytes: Uint8Array + 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') + } + let stream: ReadableStream try { - // The E2B files API returns the whole object; the remote sandbox owns - // that buffering, so the seam bound is enforced on the complete result. - bytes = await sandbox.files.read(String(target.targetKey), { format: 'bytes', ...signalOpts(signal) }) + // Same pinned-SDK quirk as streamText: 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) } - assertNotAborted(signal, 'read') - if (bytes.byteLength > maxBytes) { - throw new FsError(`cannot read "${target.displayPath}": ${bytes.byteLength} bytes exceeds the ${maxBytes}-byte limit`, 'FS_TOO_LARGE') + 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. + } + } } - return bytes + 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> { @@ -430,10 +469,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/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 44049b7972..648db7b105 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -488,6 +488,22 @@ describe('E2BFileSystem identity, metadata, and reads', () => { 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/fs/tool-fs/README.i18n.yaml b/packages/fs/tool-fs/README.i18n.yaml index b83cc55426..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: e295ad63902cbae245229fa86b780aeb59de808d -README.zh.md: be87bb18a1c49654d07977b5c8e2577188517077 +README.md: 7e334f886747cd8dc566a572c699cd80c7cf62fe +README.zh.md: b5eb5ae38aba049d77375d31d1509342a23f13fc diff --git a/packages/fs/tool-fs/README.md b/packages/fs/tool-fs/README.md index e295ad6390..7e334f8867 100644 --- a/packages/fs/tool-fs/README.md +++ b/packages/fs/tool-fs/README.md @@ -45,7 +45,7 @@ Canonical successes are `read` → `{ path, offset, lines: [{ number, text }], t 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`, a bounded `ctx.fs.readBytes` capped at `imageLimits.maxImageBytes`, `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.) +- **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.) @@ -155,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`, `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 PNG/JPEG/WebP/GIF format`; 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 diff --git a/packages/fs/tool-fs/README.zh.md b/packages/fs/tool-fs/README.zh.md index be87bb18a1..b5eb5ae38a 100644 --- a/packages/fs/tool-fs/README.zh.md +++ b/packages/fs/tool-fs/README.zh.md @@ -45,7 +45,7 @@ await ctx.plugin(ToolFs) // this package — re 工具**不**注入策略服务,也不检查任何缓存。每个工具通过 `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`、以 `imageLimits.maxImageBytes` 为上限的有界 `ctx.fs.readBytes`、`attachments.saveImage`(内容寻址,因此在 `tool/result` 事件追加时图像块引用的对象已持久提交),最后发出 `fs/observed`。(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。) @@ -125,7 +125,7 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces ### 图像读取结果 -#### 模型看到什么 +#### 模型看到的内容 成功的 `read_image` 返回 ``、`image` 和写明媒体类型、尺寸与字节数的 `` 信封,随后是作为原生图像块的图像本身。会话日志只存储持久的 `sha256:` 附件引用;路由到的提供方在每次请求时重新读取并校验字节摘要。 @@ -133,9 +133,9 @@ Use the edit tool for targeted changes to existing UTF-8 text files. It replaces 图像在之后每次请求中都会计费,直到压缩。每次调用都独立受附件存储的 `maxImageBytes`/`maxImagePixels` 约束;重复成功调用会在历史中累积,内容寻址只去重存储的字节,不去重每次请求的 token 成本。 -#### KV 缓存影响 +#### KV Cache 影响 -只追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV 缓存条目失效。 +仅追加;新可见内容跟在可复用请求前缀之后,不会使既有 KV 缓存条目失效。 ### 写入与编辑结果 @@ -155,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)`、`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 PNG/JPEG/WebP/GIF format`;提供方和策略模板在各自包的 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 影响 diff --git a/packages/fs/tool-fs/src/index.ts b/packages/fs/tool-fs/src/index.ts index 7d4d343972..44ec754091 100644 --- a/packages/fs/tool-fs/src/index.ts +++ b/packages/fs/tool-fs/src/index.ts @@ -1,5 +1,5 @@ /** - * 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 @@ -50,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 diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index b12bf52e99..f4b4d6e72f 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -60,19 +60,19 @@ export function imageMediaTypeForPath(filePath: string): ImageMediaType | undefi * 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 displayPath - the path rendered in refusal messages. + * @param requestedPath - the raw, not-yet-resolved path rendered in refusal messages. */ -export async function assertImageCapableRoute(ctx: Context, exec: ToolExecution, displayPath: string): Promise { +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 "${displayPath}" as an image: the current model route could not be resolved`) + 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 "${displayPath}" as an image: model "${model}" does not declare image input; switch to an image-capable model to read images`) + 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`) } } @@ -120,12 +120,13 @@ function imageReadContent(value: ImageReadValue): ContentBlock[] { } /** - * Register the `read_image` tool. Execution gates on the optional - * `attachments`/`llm` services and the calling route's declared image input; - * registration itself is unconditional so denial happens at the operation - * boundary rather than through schema omission. - * @param ctx - the plugin context; registrations are effects scoped to it, and - * execution uses its `fs` service plus the optional `attachments`/`llm` services. + * 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({ @@ -180,10 +181,16 @@ export function applyReadImageTool(ctx: Context): void { const target = await ctx.fs.resolve(args.file_path, sessionResolveOptions(exec, args.file_path)) const info = await ctx.fs.stat(target, exec.signal) - if (!info) throw new FsError(`cannot read "${target.displayPath}": not found`, 'FS_NOT_FOUND') + 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 data = await ctx.fs.readBytes(target, exec.signal, attachments.imageLimits.maxImageBytes) + // 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 @@ -193,7 +200,7 @@ export function applyReadImageTool(ctx: Context): void { 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 PNG/JPEG/WebP/GIF format`, + `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 }, ) } diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 229069a8d2..22946f5978 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -93,7 +93,7 @@ interface SetupOptions { resolvedModels?: LlmModelInfo[] attachments?: boolean llm?: boolean - storeConfig?: { maxImageBytes?: number; maxImagePixels?: number } + storeConfig?: { maxImageBytes?: number; maxImagePixels?: number; maxMessageImageBytes?: number } toolMode?: ToolConfig['mode'] } @@ -366,7 +366,7 @@ describe('image admission failures', () => { 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 PNG/JPEG/WebP/GIF format') + 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 () => { @@ -377,6 +377,14 @@ describe('image admission failures', () => { 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 } }) @@ -387,9 +395,12 @@ describe('image admission failures', () => { 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) @@ -430,6 +441,32 @@ describe('image admission failures', () => { }) 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']) + void remounted + + // Disposing the whole plugin withdraws every tool, read_image included. + await toolFsFiber.dispose() + expect(names()).toEqual([]) + }) + it('declares read_image parallel-safe and presents a read-family card', async () => { const ctx = await setup() expect(ctx.tools.executionMode({ From ea47c3280504c21ba795e2d20b514add43251751 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 16:09:04 +0800 Subject: [PATCH 009/113] review: one fold implementation, uniform end-edge absence, partial text in tool errors, snapshot scenario MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Address ds-review-bot on #2127: - assistant-output: the rule has ONE implementation, the incremental AssistantOutputFold (push/pushText/collect); finalAssistantOutput folds a complete suffix, the SDK backend folds notification events, and the ACP backend folds raw chunk text into the same streamed fallback. - subagent/end.lastAssistantMessage: 'no output' is encoded once — absent, never [], on both lifecycle shapes (observeRun now omits empty output). - tool-subagent: a non-completed foreground result stays isError but appends the child's preserved partial text after the stop-reason headline. - Authored keyless snapshot scenario subagent-max-tokens-partial pins the assembled transcript: the child's committed log carries the usage-only empty message and the parent's tool result carries the partial answer. - Rule-boundary sentence (message wins over later streamed text) and the consumer half recorded in the Agent Note; comments trimmed to pointers. --- ...nt-empty-terminal-message-output.i18n.yaml | 4 +- ...-subagent-empty-terminal-message-output.md | 8 +- ...bagent-empty-terminal-message-output.zh.md | 8 +- examples/acp-agent/tests/acp.snapshot.ts | 7 ++ .../subagent-max-tokens-partial/input.json | 14 +++ .../session.1.jsonl | 30 +++++++ .../subagent-max-tokens-partial/session.jsonl | 26 ++++++ .../stdout.expected.jsonl | 4 + packages/subagent/subagent-acp/src/run.ts | 18 ++-- packages/subagent/subagent-dsh-sdk/src/run.ts | 26 ++---- .../subagent/subagent-inprocess/src/index.ts | 4 +- packages/subagent/subagent/README.i18n.yaml | 4 +- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/assistant-output.ts | 86 ++++++++++++------- packages/subagent/subagent/src/index.ts | 2 +- packages/subagent/subagent/src/lifecycle.ts | 4 +- .../subagent/tests/assistant-output.spec.ts | 25 +++--- .../subagent/subagent/tests/service.spec.ts | 12 +++ .../subagent/tool-subagent/README.i18n.yaml | 4 +- packages/subagent/tool-subagent/README.md | 2 +- packages/subagent/tool-subagent/README.zh.md | 2 +- packages/subagent/tool-subagent/src/index.ts | 20 ++++- .../tool-subagent/tests/tool-subagent.spec.ts | 3 + 24 files changed, 221 insertions(+), 96 deletions(-) create mode 100644 examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/input.json create mode 100644 examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.jsonl create mode 100644 examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/stdout.expected.jsonl 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 index 9ec2d8bb33..537cb88062 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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: ece2a55930aabfaeaf88ef1757918296b32bfea5 -2026-08-10-subagent-empty-terminal-message-output.zh.md: 00b8f5bd7bd8e71935f68af2c35521f2b2377186 +2026-08-10-subagent-empty-terminal-message-output.md: d90047c07a300a1afbc42c7db1a4fefa25d56764 +2026-08-10-subagent-empty-terminal-message-output.zh.md: 0a5ce02dccef422dc75bc980d104f41f116427f2 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 index ece2a55930..d90047c07a 100644 --- 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 @@ -10,9 +10,11 @@ The agent loop appends an EMPTY-content `assistant/message` when a `max-tokens` ## Decision -`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: the last NON-EMPTY assistant message wins; without one, the accumulated `text-delta` stream is the answer; empty-content messages never contribute. `finalAssistantOutput(events)` applies the rule to an event suffix (the in-process `readResult` and the Activation capture), and `assistantMessageOutput(event)` is the same per-event predicate for the SDK backend's incremental fold. The contract is stated once at `SubagentResult.output` and mirrored by the subsystem reference; `subagent/end.lastAssistantMessage` declares it selects by the same rule. A `max-tokens` or `aborted` finish still reports its honest stop reason; only output selection changed. +`dsh-subagent` owns one canonical selection rule in `src/assistant-output.ts`: the last NON-EMPTY assistant message wins; without one, the accumulated `text-delta` stream is the answer; empty-content messages never contribute. The rule has one implementation, the incremental `AssistantOutputFold` (`push(event)` for session-event transports, `pushText(text)` for chunk-only transports, `collect()` to select), and `finalAssistantOutput(events)` applies it to a complete event suffix (the in-process `readResult` and the Activation capture). The SDK backend folds notification events; the ACP backend, which surfaces no complete assistant messages, folds raw chunk text into the same streamed fallback. The contract is stated once at `SubagentResult.output` and mirrored by the subsystem reference; `subagent/end.lastAssistantMessage` selects by the same rule, and "no output" has one encoding on that edge — the field is absent, never an empty array, on both the one-shot and continuable lifecycle shapes. A `max-tokens` or `aborted` finish still reports its honest stop reason; only output selection changed. -The ACP backend accumulates chunks only and was never affected. The fake SDK runtime gained a `FAKE_EMPTY_MESSAGE` mode so the keyless backend test can script a usage-only terminal message. +The foreground delegation tool observes the same selection: a non-`completed` result stays an `isError` tool result, but its message appends the child's preserved partial text after the stop-reason headline, so the parent model sees the truncated answer instead of a bare failure. + +The fake SDK runtime gained a `FAKE_EMPTY_MESSAGE` mode so the keyless backend test can script a usage-only terminal message, and the authored `subagent-max-tokens-partial` ACP snapshot scenario pins the assembled transcript: a scripted child streams text plus a tool call, is cut off by a tool-only max-tokens step (the empty usage-only message appears in its committed log), and the parent's tool result carries the partial answer. ## Alternatives considered @@ -24,4 +26,4 @@ The ACP backend accumulates chunks only and was never affected. The fake SDK run ## Consequences -Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children keep the text streamed before the abort; one-shot and continuable `subagent/end` edges agree with `SubagentResult.output`. A message whose content is non-empty but textless (for example reasoning-only) still wins over streamed text — the rule is about empty content, not text presence. Regression tests in all three packages script the empty-terminal-message and cancel paths and fail under the previous selections. +Multi-step children cut off at max-tokens report their earlier text; cancelled in-process children keep the text streamed before the abort; one-shot and continuable `subagent/end` edges agree with `SubagentResult.output`. A message whose content is non-empty but textless (for example reasoning-only) still wins over streamed text — the rule is about empty content, not text presence. A non-empty message also wins over text streamed AFTER it: a child cancelled while streaming a later step reports its earlier complete message, matching the SDK backend's documented contract, with the stop reason signalling the truncation. Regression tests in all three packages script the empty-terminal-message and cancel paths and fail under the previous selections. 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 index 00b8f5bd7b..0a5ce02dcc 100644 --- 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 @@ -10,9 +10,11 @@ Status: implemented ## 决策 -`dsh-subagent` 在 `src/assistant-output.ts` 中拥有唯一的规范选取规则:最后一条**非空** assistant 消息优先;没有时,累积的 `text-delta` 流就是回答;空内容消息从不参与。`finalAssistantOutput(events)` 把该规则应用于事件后缀(进程内 `readResult` 与 Activation capture),`assistantMessageOutput(event)` 是同一规则的逐事件谓词,供 SDK 后端的增量折叠使用。契约在 `SubagentResult.output` 处声明一次,并由子系统参考文档镜像;`subagent/end.lastAssistantMessage` 声明按同一规则选取。`max-tokens` 或 `aborted` 终止仍然如实上报其终止原因;只有输出选取发生了变化。 +`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` 按同一规则选取,且"无输出"在该边沿只有一种编码——字段缺省,绝不是空数组,一次性与 continuable 两种生命周期形态一致。`max-tokens` 或 `aborted` 终止仍然如实上报其终止原因;只有输出选取发生了变化。 -ACP 后端只累积分块,从未受影响。fake SDK runtime 新增 `FAKE_EMPTY_MESSAGE` 模式,使无密钥后端测试能够脚本化一条仅承载 usage 的终止消息。 +前台委派工具观察同一选取结果:非 `completed` 的结果仍是 `isError` 工具结果,但其消息在终止原因标题之后附带子代理保留下来的部分文本,父模型看到的是被截断的回答而不是一句干巴巴的失败。 + +fake SDK runtime 新增 `FAKE_EMPTY_MESSAGE` 模式,使无密钥后端测试能够脚本化一条仅承载 usage 的终止消息;authored 的 `subagent-max-tokens-partial` ACP snapshot 场景钉住了组装后的 transcript:脚本化的子代理先流式输出文本和一次工具调用,再被仅含工具调用的 max-tokens 步骤截断(空的 usage-only 消息出现在其提交的日志中),父侧工具结果携带部分回答。 ## 考虑过的替代方案 @@ -24,4 +26,4 @@ ACP 后端只累积分块,从未受影响。fake SDK runtime 新增 `FAKE_EMPT ## 后果 -被 max-tokens 截断的多步子代理会报告其更早的文本;被取消的进程内子代理保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 边沿与 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning)仍然优先于流式文本——规则针对的是内容为空,而非文本缺失。三个包中的回归测试脚本化了空终止消息与取消路径,并在先前的选取实现下失败。 +被 max-tokens 截断的多步子代理会报告其更早的文本;被取消的进程内子代理保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 边沿与 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning)仍然优先于流式文本——规则针对的是内容为空,而非文本缺失。非空消息同样优先于**其后**才流式出的文本:子代理在流式后续步骤时被取消,报告的是更早那条完整消息,与 SDK 后端文档化的契约一致,截断由终止原因示意。三个包中的回归测试脚本化了空终止消息与取消路径,并在先前的选取实现下失败。 diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index af044fc58f..852f9228ff 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -299,6 +299,13 @@ 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 (like error-finish): a live child cannot be coaxed into + // a max-tokens step that assembled ONLY tool-call blocks — the truncation + // shape whose usage-only empty assistant/message must not erase the child's + // earlier text. The child fixture scripts text + todo_write, then a + // tool-only max-tokens cutoff; the parent's subagent tool result must carry + // the child's real partial answer with the max-tokens stop reason. + { name: 'subagent-max-tokens-partial', hasModelTurn: true, recorded: false }, { name: 'subagent-multi', hasModelTurn: true, recorded: true }, { name: 'subagent-fork', hasModelTurn: true, recorded: true }, { name: 'subagent-mixed', hasModelTurn: true, recorded: true }, 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..55261e7a95 --- /dev/null +++ b/examples/acp-agent/tests/snapshots/subagent-max-tokens-partial/session.1.jsonl @@ -0,0 +1,30 @@ +{"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":"agent/inbox/spliced","seq":0,"time":1786348800126,"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":1,"time":1786348800126,"data":{"turn":1}} +{"type":"agent/inbox/spliced","seq":2,"time":1786348800126,"data":{"target":"next-turn","start":0,"removedCount":1,"inserted":[]}} +{"type":"subagent/descriptor","seq":3,"time":1786348800139,"data":{"version":2,"mode":"one-shot","provider":"spawn","label":"Truncated child"}} +{"type":"step/start","seq":4,"time":1786348800142,"data":{"turn":1,"step":1}} +{"type":"user/message","seq":5,"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":6,"time":1786348800142,"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":"ff5bdbe7-2eb0-4380-8edb-0e5c58ba9840"},"surfaceOp":"append"} +{"type":"session/title","seq":7,"time":1786348800142,"data":{"title":"Write the words 'partial one',","messageSeqs":[5],"source":{"kind":"fallback"}}} +{"type":"request/header","seq":8,"time":1786348800142,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}"},"reason":"initial"}} +{"type":"request/context","seq":9,"time":1786348800142,"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":"text"}}} +{"type":"assistant/chunk","seq":11,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"text","text":"partial one"}}}} +{"type":"assistant/chunk","seq":12,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":13,"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":14,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":20,"outputTokens":9}}}} +{"type":"assistant/chunk","seq":15,"time":1786348800146,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"tool-calls"}}}} +{"type":"assistant/message","seq":16,"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":[10,11,12,13,14,15],"surfaceOp":"append"} +{"type":"tool/call","seq":17,"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":18,"time":1786348800150,"data":{"todos":[{"content":"keep going","status":"in_progress"}]}} +{"type":"tool/result","seq":19,"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":[17],"surfaceOp":"append"} +{"type":"step/end","seq":20,"time":1786348800151,"data":{"turn":1,"step":1}} +{"type":"step/start","seq":21,"time":1786348800156,"data":{"turn":1,"step":2}} +{"type":"assistant/chunk","seq":22,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"block-start","index":0,"blockType":"tool-call"}}} +{"type":"assistant/chunk","seq":23,"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":24,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"usage","usage":{"inputTokens":30,"outputTokens":4}}}} +{"type":"assistant/chunk","seq":25,"time":1786348800160,"data":{"turn":1,"step":2,"chunk":{"type":"finish","reason":{"kind":"max-tokens"}}}} +{"type":"assistant/message","seq":26,"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":[22,23,24,25],"surfaceOp":"append"} +{"type":"step/end","seq":27,"time":1786348800160,"data":{"turn":1,"step":2}} +{"type":"turn/end","seq":28,"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..57386320e4 --- /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":"309b09a3-9593-4161-903d-cb5b14d8e9d9"},"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/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index 17476be41c..c57643411a 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,10 @@ 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[] = [] + // The child's streamed assistant text, accumulated under the seam's + // canonical selection rule (`AssistantOutputFold`); ACP surfaces no complete + // assistant messages, so only the streamed-fallback half applies. + const fold = new AssistantOutputFold() // Shared mutable state keeps cancellation visible across async closures. const flags = { cancelled: false } @@ -241,7 +244,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 +287,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-dsh-sdk/src/run.ts b/packages/subagent/subagent-dsh-sdk/src/run.ts index 39bf53fffb..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 { assistantMessageOutput, 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,28 +163,14 @@ export async function startSdkRun(request: SubagentStartRequest, spec: SdkRunSpe } const childSessionId = `session-${randomUUID().replaceAll('-', '')}` - // The child's final answer, folded incrementally under the seam's canonical - // rule (`finalAssistantOutput`): the last NON-EMPTY complete assistant - // message when one exists, else the text streamed so far (a partial answer - // surviving cancel). An empty-content message hosts only usage (a max-tokens - // step that assembled no text blocks), so it never erases streamed text. - 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 - const content = assistantMessageOutput(event) - if (content !== undefined) { - lastMessage = content - } else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { - partial.push(event.data.chunk.text) - } - } - 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-inprocess/src/index.ts b/packages/subagent/subagent-inprocess/src/index.ts index d621674a6e..0f09e8cfa4 100644 --- a/packages/subagent/subagent-inprocess/src/index.ts +++ b/packages/subagent/subagent-inprocess/src/index.ts @@ -220,9 +220,7 @@ function readResult( ): SubagentResult { const own = child.session.events.slice(boundary) const lastEnd = findLastMessageTurnEnd(own) - // Canonical selection (`finalAssistantOutput`): the last non-empty assistant - // message, else the text streamed before cancel/error/truncation cut the - // turn short — an empty usage-only message never erases real output. + // 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 diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 2b47a91a4b..81fcd7d10b 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: d2d5356fd82a47ecf5cd6b633e5338dde7047901 -README.zh.md: 2fdc3ae6e8376ef7c7aaf11c8e85dd909ef92d2c +README.md: 843fd0af4a86ea3a10d4a4ee101aa05478a2300e +README.zh.md: 497f2a928c8eaeff8ef0486f5344c3d257be7391 diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index d2d5356fd8..843fd0af4a 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -56,7 +56,7 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th `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. `output` and the `subagent/end` edge's `lastAssistantMessage` share one selection rule, implemented by the exported `finalAssistantOutput` helper: the child's last non-empty assistant message, else the text it streamed before the turn was cut short ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the contract). +`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` edge's `lastAssistantMessage` share one selection rule, implemented once by the exported `AssistantOutputFold`/`finalAssistantOutput` helpers: the child's last non-empty assistant message, else the text it streamed before the turn was cut short ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the 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. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 2fdc3ae6e8..497f2a928c 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -56,7 +56,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output` 与 `subagent/end` 边沿的 `lastAssistantMessage` 共用同一条选取规则,由导出的 `finalAssistantOutput` 辅助函数实现:取子 agent 最后一条非空 assistant 消息,否则取轮次被截断前已流式的文本(契约归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 +`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output` 与 `subagent/end` 边沿的 `lastAssistantMessage` 共用同一条选取规则,由导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数唯一实现:取子 agent 最后一条非空 assistant 消息,否则取轮次被截断前已流式的文本(契约归 [`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 会话,其一次性运行不会进入基于追踪的枚举结果。 diff --git a/packages/subagent/subagent/src/assistant-output.ts b/packages/subagent/subagent/src/assistant-output.ts index 5fea60fa89..5b11031c19 100644 --- a/packages/subagent/subagent/src/assistant-output.ts +++ b/packages/subagent/subagent/src/assistant-output.ts @@ -1,12 +1,12 @@ /** - * Canonical selection of a child's final assistant output from its session - * events. Every surface that reports "the child's answer" — backend run - * results and `subagent/end.lastAssistantMessage` — applies this one rule so - * observers agree: the last NON-EMPTY assistant message wins; an empty-content - * message hosts only usage (the loop appends one when a max-tokens step - * assembled no executable blocks) and never erases real output; without any - * non-empty message, the text streamed so far is the answer (a partial - * surviving cancel, error, and truncation paths). + * Canonical selection of a child's final assistant output. Every surface that + * reports "the child's answer" — backend run results and + * `subagent/end.lastAssistantMessage` — applies this one rule so observers + * agree: the last NON-EMPTY assistant message wins; an empty-content message + * hosts only usage (the loop appends one when a max-tokens step assembled no + * executable blocks) and never erases real output; without any non-empty + * message, the text streamed so far is the answer (a partial surviving + * cancel, error, and truncation paths). * * @module @deepseek-ai/dsh-subagent/assistant-output */ @@ -15,37 +15,57 @@ import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' /** - * The content one event contributes as a candidate final answer: an - * `assistant/message` with non-empty content. An empty-content message hosts - * only usage and contributes none. - * @param event - any session event. - * @returns the message content, or `undefined` when this event is not a - * non-empty assistant message. + * 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 function assistantMessageOutput(event: SessionEvent): ContentBlock[] | undefined { - if (event.type !== 'assistant/message') return undefined - const content = event.data.message.content - return content.length > 0 ? content : undefined +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.partial.push(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 { + 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 + } } /** - * Select the final assistant output from one child-owned event suffix: the - * last non-empty assistant message, else the accumulated `text-delta` stream. + * 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 { - let message: ContentBlock[] | undefined - const partial: string[] = [] - for (const event of events) { - const content = assistantMessageOutput(event) - if (content !== undefined) { - message = content - } else if (event.type === 'assistant/chunk' && event.data.chunk.type === 'text-delta') { - partial.push(event.data.chunk.text) - } - } - if (message !== undefined) return message - const text = partial.join('') - return text.length > 0 ? [{ type: 'text', text }] : undefined + const fold = new AssistantOutputFold() + for (const event of events) fold.push(event) + return fold.collect() } diff --git a/packages/subagent/subagent/src/index.ts b/packages/subagent/subagent/src/index.ts index d3e26e6dbc..e39d5eec70 100644 --- a/packages/subagent/subagent/src/index.ts +++ b/packages/subagent/subagent/src/index.ts @@ -69,7 +69,7 @@ import { snapshotSubagentDescriptor } from './descriptor.ts' import { subagentIdentityProjectionDefinition, subagentTimingProjectionDefinition } from './projection.ts' export * from './out-of-process.ts' -export { assistantMessageOutput, finalAssistantOutput } from './assistant-output.ts' +export { AssistantOutputFold, finalAssistantOutput } from './assistant-output.ts' export { SubagentRunId } from './types.ts' export type { ContinuableCreateRequest, diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index 26fdd54256..df050f5538 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -129,7 +129,9 @@ export function observeRun( emit('subagent/end', { ...identity, stopReason: result.stopReason, - lastAssistantMessage: result.output, + // One encoding for "no output" across both lifecycle shapes: the + // field is absent, matching the continuable epoch edge. + ...result.output.length === 0 ? {} : { lastAssistantMessage: result.output }, }, parent) }, () => { diff --git a/packages/subagent/subagent/tests/assistant-output.spec.ts b/packages/subagent/subagent/tests/assistant-output.spec.ts index 5219209249..2205431aae 100644 --- a/packages/subagent/subagent/tests/assistant-output.spec.ts +++ b/packages/subagent/subagent/tests/assistant-output.spec.ts @@ -1,7 +1,7 @@ import { describe, expect, it } from 'vitest' import type { ContentBlock } from '@deepseek-ai/dsh-llm' import type { SessionEvent } from '@deepseek-ai/dsh-session' -import { assistantMessageOutput, finalAssistantOutput } from '../src/assistant-output.ts' +import { AssistantOutputFold, finalAssistantOutput } from '../src/assistant-output.ts' function message(content: ContentBlock[]): SessionEvent { return { type: 'assistant/message', data: { message: { content } } } as SessionEvent @@ -15,15 +15,6 @@ function reasoningDelta(text: string): SessionEvent { return { type: 'assistant/chunk', data: { chunk: { type: 'reasoning-delta', text } } } as SessionEvent } -describe('assistantMessageOutput', () => { - it('returns content only for a non-empty assistant message', () => { - const content: ContentBlock[] = [{ type: 'text', text: 'answer' }] - expect(assistantMessageOutput(message(content))).toBe(content) - expect(assistantMessageOutput(message([]))).toBeUndefined() - expect(assistantMessageOutput(textDelta('chunk'))).toBeUndefined() - }) -}) - describe('finalAssistantOutput', () => { it('selects the last non-empty message past a later empty usage-only message', () => { const events = [ @@ -58,3 +49,17 @@ describe('finalAssistantOutput', () => { 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/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index a50696cf2a..b46f2489fa 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -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', })) + // "No output" has ONE encoding on the end edge: the field is absent, + // never an empty array, matching the continuable epoch edge. + 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/tool-subagent/README.i18n.yaml b/packages/subagent/tool-subagent/README.i18n.yaml index e5f0c43f61..8c41c6413a 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: ac3ec0563cce9128608ca31860b034a103dc1a3a +README.zh.md: d64831d7cf64800ad3307ce6cb7f294500a0a6f0 diff --git a/packages/subagent/tool-subagent/README.md b/packages/subagent/tool-subagent/README.md index 6ec313b3b9..ac3ec0563c 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). diff --git a/packages/subagent/tool-subagent/README.zh.md b/packages/subagent/tool-subagent/README.zh.md index 1fd88363b3..d64831d7cf 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)。 diff --git a/packages/subagent/tool-subagent/src/index.ts b/packages/subagent/tool-subagent/src/index.ts index 67894c32cb..b2743054c4 100644 --- a/packages/subagent/tool-subagent/src/index.ts +++ b/packages/subagent/tool-subagent/src/index.ts @@ -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', diff --git a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts index 91dc423cd4..ed83696358 100644 --- a/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts +++ b/packages/subagent/tool-subagent/tests/tool-subagent.spec.ts @@ -154,6 +154,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 () => { From ccbaedc8a888ddbb3233e7925432e0cdb3c93b77 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 16:29:55 +0800 Subject: [PATCH 010/113] test(scaffold-server): expect absent lastAssistantMessage for a childless result The subagent/end edge now encodes 'no output' as an absent field, never an empty array; the wire projection forwards only present fields. --- packages/scaffold/server/tests/built-scope-carrier.e2e.ts | 3 ++- packages/scaffold/server/tests/server.spec.ts | 3 ++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/packages/scaffold/server/tests/built-scope-carrier.e2e.ts b/packages/scaffold/server/tests/built-scope-carrier.e2e.ts index b3519110b5..fdd5276352 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 childless result carries NO lastAssistantMessage on the wire: the end + // edge encodes "no output" as an absent field, never `[]`. 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/server.spec.ts b/packages/scaffold/server/tests/server.spec.ts index 714fc0ada3..7b375f1a97 100644 --- a/packages/scaffold/server/tests/server.spec.ts +++ b/packages/scaffold/server/tests/server.spec.ts @@ -736,6 +736,8 @@ describe('HarnessSdkServer', () => { stopReason: 'error', }) + // A childless result carries NO lastAssistantMessage on the wire: the + // end edge encodes "no output" as an absent field, never `[]`. 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({ From 0a4bdd8ee806a0eeaaf523dba3057717d710f58a Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 17:06:07 +0800 Subject: [PATCH 011/113] docs(subagent): note the settlement-fold optimization condition --- packages/subagent/subagent/src/assistant-output.ts | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/packages/subagent/subagent/src/assistant-output.ts b/packages/subagent/subagent/src/assistant-output.ts index 5b11031c19..a617060390 100644 --- a/packages/subagent/subagent/src/assistant-output.ts +++ b/packages/subagent/subagent/src/assistant-output.ts @@ -65,6 +65,10 @@ export class AssistantOutputFold { * @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() From 528c776e1b54fb047f1ccf3d6ecef57dacb0d1b3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 19:11:06 +0800 Subject: [PATCH 012/113] fix(e2b): release bounded read stream locks Release the Web Stream reader after either completion or cancellation. Strengthen the oversized-file test so a regression that downloads content before applying the stat bound fails directly. --- packages/e2b/fs-e2b/src/index.ts | 1 + packages/e2b/fs-e2b/tests/filesystem.spec.ts | 5 +++++ 2 files changed, 6 insertions(+) diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index 75005fa3ad..eda6866116 100644 --- a/packages/e2b/fs-e2b/src/index.ts +++ b/packages/e2b/fs-e2b/src/index.ts @@ -274,6 +274,7 @@ export class E2BFileSystem extends FileSystem { // remote stream adds nothing actionable for the caller. } } + reader.releaseLock() } const whole = new Uint8Array(bytes) let offset = 0 diff --git a/packages/e2b/fs-e2b/tests/filesystem.spec.ts b/packages/e2b/fs-e2b/tests/filesystem.spec.ts index 648db7b105..a2a494911f 100644 --- a/packages/e2b/fs-e2b/tests/filesystem.spec.ts +++ b/packages/e2b/fs-e2b/tests/filesystem.spec.ts @@ -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 @@ -478,7 +480,10 @@ describe('E2BFileSystem identity, metadata, and reads', () => { 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') From 21ec0fd10b22f61e808f587cced60b93f0cf4ce3 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 19:32:37 +0800 Subject: [PATCH 013/113] test(cli): expect read_image in standard preset --- apps/cli/tests/web-agent-presets.e2e.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/cli/tests/web-agent-presets.e2e.ts b/apps/cli/tests/web-agent-presets.e2e.ts index 1bfaed8c67..43e63fc15a 100644 --- a/apps/cli/tests/web-agent-presets.e2e.ts +++ b/apps/cli/tests/web-agent-presets.e2e.ts @@ -133,7 +133,7 @@ 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', + 'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill', 'str_replace_editor', 'subagent', 'subagent_fork', 'task_kill', 'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search', 'workflow', 'write', From 767e5a57236b3828171db766e694d323cc68e2cd Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 19:56:18 +0800 Subject: [PATCH 014/113] fix(fs): close read-image review gaps --- packages/fs/fs/README.i18n.yaml | 4 +-- packages/fs/fs/README.md | 2 +- packages/fs/fs/README.zh.md | 2 +- packages/fs/tool-fs/tests/read-image.spec.ts | 2 +- packages/support/llm-replay/README.i18n.yaml | 4 +-- packages/support/llm-replay/README.md | 2 +- packages/support/llm-replay/README.zh.md | 2 +- packages/support/llm-replay/src/index.ts | 17 +++++++++++++ .../llm-replay/tests/llm-replay.spec.ts | 25 +++++++++++++++++-- 9 files changed, 49 insertions(+), 11 deletions(-) diff --git a/packages/fs/fs/README.i18n.yaml b/packages/fs/fs/README.i18n.yaml index 9eb2da496a..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: 4e3a37b997c9d377ebb3795f61f9070a8a9c38ea -README.zh.md: 50551d3752acf4ffc12ae40c711c9289fd4d87d7 +README.md: d53fe69456622e533e5ba5a96bd9dab10c188eaa +README.zh.md: 64b7d79687a0b6a0d81a5037ea6044d4a406f32b diff --git a/packages/fs/fs/README.md b/packages/fs/fs/README.md index 4e3a37b997..d53fe69456 100644 --- a/packages/fs/fs/README.md +++ b/packages/fs/fs/README.md @@ -48,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 diff --git a/packages/fs/fs/README.zh.md b/packages/fs/fs/README.zh.md index 50551d3752..64b7d79687 100644 --- a/packages/fs/fs/README.zh.md +++ b/packages/fs/fs/README.zh.md @@ -48,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`。 ## 模型体验 diff --git a/packages/fs/tool-fs/tests/read-image.spec.ts b/packages/fs/tool-fs/tests/read-image.spec.ts index 22946f5978..5f8ca7205c 100644 --- a/packages/fs/tool-fs/tests/read-image.spec.ts +++ b/packages/fs/tool-fs/tests/read-image.spec.ts @@ -460,11 +460,11 @@ describe('registration surface', () => { // Remounting the store restores the conditional registration. const remounted = await ctx.plugin(LocalAttachmentStore, { dshHome: home }) expect(names()).toEqual(['edit', 'read', 'read_image', 'write']) - void remounted // 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 () => { diff --git a/packages/support/llm-replay/README.i18n.yaml b/packages/support/llm-replay/README.i18n.yaml index 4cef8ce1c7..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: ade19b66f96ef26119944cbebba23c43e4b2117c -README.zh.md: 4752e03b04be175d7f3e37fc57fe9c74e1f8a303 +README.md: 6119407c06cf734166300f5f70a3fe65d026d08d +README.zh.md: 75d74b654a9a53e7ada5cc854e275eadcbe01284 diff --git a/packages/support/llm-replay/README.md b/packages/support/llm-replay/README.md index ade19b66f9..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` and declared `inputModalities` (so a scenario can exercise capability gates such as the image-capable `read_image` route check); 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 4752e03b04..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` 和声明的 `inputModalities`(让场景能够触发能力门禁,例如 `read_image` 的图像路由检查);已配置路由通过回放适配器分派,绝不执行提供方 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/src/index.ts b/packages/support/llm-replay/src/index.ts index 416c2386fd..8c0f574453 100644 --- a/packages/support/llm-replay/src/index.ts +++ b/packages/support/llm-replay/src/index.ts @@ -765,11 +765,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/tests/llm-replay.spec.ts b/packages/support/llm-replay/tests/llm-replay.spec.ts index 0c36dca1ff..1eb4f0398b 100644 --- a/packages/support/llm-replay/tests/llm-replay.spec.ts +++ b/packages/support/llm-replay/tests/llm-replay.spec.ts @@ -7,6 +7,7 @@ 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, @@ -1097,11 +1098,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') From 00f68d7e9339105184d7f13dcf89a17ce80f3fe5 Mon Sep 17 00:00:00 2001 From: lintianle Date: Mon, 10 Aug 2026 18:50:03 +0800 Subject: [PATCH 015/113] feat(mcp-client): auto-reconnect with bounded backoff after transport close MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A per-instance connection supervisor restarts the original server config with exponential backoff when the transport closes, re-runs tool discovery on success, and atomically replaces the previous generation. Default policy retries for ~2.5 minutes (10 attempts, 500ms→30s doubling) before giving up and unregistering the server's tools. New config block reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts } on both transports; misconfiguration fails plugin load. A connection that survives past the stability window (maxDelayMs) resets the attempt budget, so occasional crashes recover indefinitely while a crash loop still exhausts the cap. Integrates with the upstream failOnStartupError: the initial sync uses registrationFailure:'throw' when that flag is set so a squatted namespace still rejects activation. Fixes #1746 --- .../2026-07-07-mcp-client-plugin.i18n.yaml | 4 +- .../feature/2026-07-07-mcp-client-plugin.md | 12 +- .../2026-07-07-mcp-client-plugin.zh.md | 12 +- ...-08-06-mcp-client-auto-reconnect.i18n.yaml | 6 + .../2026-08-06-mcp-client-auto-reconnect.md | 48 ++ ...2026-08-06-mcp-client-auto-reconnect.zh.md | 48 ++ docs/config-catalog.i18n.yaml | 4 +- docs/config-catalog.md | 18 +- docs/config-catalog.zh.md | 16 + docs/module-graph.i18n.yaml | 4 +- docs/module-graph.md | 3 +- docs/module-graph.zh.md | 3 +- packages/mcp/mcp-client/README.i18n.yaml | 4 +- packages/mcp/mcp-client/README.md | 14 +- packages/mcp/mcp-client/README.zh.md | 14 +- packages/mcp/mcp-client/package.json | 2 + packages/mcp/mcp-client/src/connection.ts | 292 +++++++++++++ packages/mcp/mcp-client/src/index.ts | 90 ++-- packages/mcp/mcp-client/tests/apply.spec.ts | 31 +- .../mcp/mcp-client/tests/fixture-server.ts | 11 + .../mcp/mcp-client/tests/mcp-client.e2e.ts | 83 +++- .../mcp/mcp-client/tests/reconnect.spec.ts | 413 ++++++++++++++++++ packages/mcp/mcp-client/tsconfig.json | 3 + pnpm-lock.yaml | 3 + 24 files changed, 1044 insertions(+), 94 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.md create mode 100644 .agents/notes/implemented/feature/2026-08-06-mcp-client-auto-reconnect.zh.md create mode 100644 packages/mcp/mcp-client/src/connection.ts create mode 100644 packages/mcp/mcp-client/tests/reconnect.spec.ts 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-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..d9ec007b53 --- /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: b187f5de71a10ca3121d817f383a98669246c81f +2026-08-06-mcp-client-auto-reconnect.zh.md: a4ec6897c34f2142ceb95abecd13d189c4a95624 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..b187f5de71 --- /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. Failure signals are idempotent per generation: a connect rejection racing its own transport close schedules exactly one retry. + +**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.** Reconnecting logs at warn with attempt count and delay, recovery at info, final failure and disabled-loss 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, 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..a4ec6897c3 --- /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` 通知同时触发重新同步。失败信号按代幂等:一次连接拒绝与其自身 transport 关闭竞态时,仅调度恰好一次重试。 + +**有界退避与故障预算。** 延迟从 `initialDelayMs` 起逐次翻倍,上限为 `maxDelayMs`。一次故障期间共享 `maxAttempts` 次连续失败尝试的预算;耗尽后注销该服务器的工具、以 error 级别记录日志并停止,直到 dispose 或重新加载。连接在存活超过稳定窗口——即 `maxDelayMs`,作为最长退避间隔从配置推导得出而非作为第五个独立调参项——之后重置预算;因此偶尔崩溃的服务器可无限恢复,而连接短暂成功后立即再次崩溃的循环无法将其预算洗白为重启风暴。 + +**配置与解析。** 两种传输均接受 `reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }` 配置,Schemastery 默认值为(启用、500ms、30s、10)。`resolveReconnectPolicy()` 是显式的解析步骤:它重新校验每个边界值和跨字段约束,因为程序化构造可能绕过 Schemastery,配置错误在加载时即令插件实例失败。 + +**可观测状态。** 重连中以 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):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、失败上限注销工具并停止、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/docs/config-catalog.i18n.yaml b/docs/config-catalog.i18n.yaml index 09c961ef69..6a90ab01d5 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: 51c6ae46eeca1279390c9d9315a6161edd2de618 -config-catalog.zh.md: dc93f5b4b55b07c52c58405ba4793c2c6eca28df +config-catalog.md: 76f1d0344d3e172416af36eff27bd4cb8544ed4a +config-catalog.zh.md: d5752a1922d8b8b2e70f055ec05117ed20c445bf diff --git a/docs/config-catalog.md b/docs/config-catalog.md index 51c6ae46ee..76f1d0344d 100644 --- a/docs/config-catalog.md +++ b/docs/config-catalog.md @@ -1069,6 +1069,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). */ @@ -1089,10 +1091,24 @@ 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:104`](../packages/mcp/mcp-client/src/index.ts) ## `@deepseek-ai/dsh-permission` diff --git a/docs/config-catalog.zh.md b/docs/config-catalog.zh.md index dc93f5b4b5..d5752a1922 100644 --- a/docs/config-catalog.zh.md +++ b/docs/config-catalog.zh.md @@ -1071,6 +1071,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). */ @@ -1091,6 +1093,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 +} + +/** 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 } ``` diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index d478f79cd1..349fadadec 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: 6763ca6e84a5cc2e5e776a56e5cfc20b710017aa -module-graph.zh.md: 339345b39a8225c34ecf0ba73efff34d4b940046 +module-graph.md: 78beee5f9311ae4c75a9ac291d1c7882914bfb8f +module-graph.zh.md: 6b71b0997ce542d53285cd815277556dba46b353 diff --git a/docs/module-graph.md b/docs/module-graph.md index 6763ca6e84..78beee5f93 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -920,6 +920,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 @@ -1403,7 +1404,7 @@ flowchart TD | [`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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index 339345b39a..6b71b0997c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -922,6 +922,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 @@ -1405,7 +1406,7 @@ flowchart TD | [`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) | 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..18f4cac7a8 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -28,6 +28,7 @@ "@deepseek-ai/dsh-invariants": "^0.0.1", "@deepseek-ai/dsh-llm": "^0.0.1", "@deepseek-ai/dsh-subprocess": "^0.0.1", + "@deepseek-ai/dsh-timeout": "^0.0.1", "@deepseek-ai/dsh-tools": "^0.0.1", "cordis": "^4.0.0-rc.7" }, @@ -40,6 +41,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:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", diff --git a/packages/mcp/mcp-client/src/connection.ts b/packages/mcp/mcp-client/src/connection.ts new file mode 100644 index 0000000000..47bf3c8003 --- /dev/null +++ b/packages/mcp/mcp-client/src/connection.ts @@ -0,0 +1,292 @@ +/** + * 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 '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, +}) + +/** 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 isFirstSync = true + + let disposed = false + /** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */ + let client: Client | 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): Promise { + const syncOpts = isFirstSync ? startupOpts : opts + isFirstSync = false + 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 + scheduleReconnect() + } + + function scheduleReconnect(): void { + if (!policy.enabled) { + const detail = connectedAt !== undefined + ? 'registered tools will fail until an HMR reload or Host restart' + : 'no tools were registered; reload the plugin or restart the Host to connect' + ctx.logger.error(`${label}: connection lost and reconnect is disabled — ${detail}`) + 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)) + ctx.logger.warn(`${label}: connection lost; reconnecting in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`) + reconnectTimer = setTimeout(() => { + reconnectTimer = undefined + settling = connectGeneration() + }, 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. Every failure funnels through {@link generationDown}; success arms + * the onclose-driven disconnect path. Never rejects. + */ + async function connectGeneration(): Promise { + const generation = new Client( + { name: 'dsh-mcp-client', version: '0.0.1' }, + { capabilities: {} }, + ) + client = generation + generation.onclose = () => { 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)) + await enqueueSync(generation) + } catch (error) { + if (firstAttemptError === undefined) firstAttemptError = error + // When the transport closed first, onclose already logged and scheduled. + if (isCurrent(generation)) ctx.logger.warn(`${label}: connection attempt failed: ${String(error)}`) + try { await generation.close() } catch { /* transport already gone */ } + 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() + + // 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 + client = undefined + if (current !== undefined) { + try { await current.close() } catch { /* transport already gone */ } + } + // 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..6696b7ce15 100644 --- a/packages/mcp/mcp-client/src/index.ts +++ b/packages/mcp/mcp-client/src/index.ts @@ -15,14 +15,14 @@ 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 { 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' @@ -74,6 +74,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 +96,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 +120,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 +129,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 +144,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 +166,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/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index e30a1ee716..f2a9982d59 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -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)', () => { @@ -216,8 +243,8 @@ 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() 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/mcp-client.e2e.ts b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts index e1f51d20e9..7f09b7afae 100644 --- a/packages/mcp/mcp-client/tests/mcp-client.e2e.ts +++ b/packages/mcp/mcp-client/tests/mcp-client.e2e.ts @@ -13,7 +13,7 @@ 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 { afterAll, beforeAll, describe, expect, it, vi } from 'vitest' import { Context } from 'cordis' import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js' import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js' @@ -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/reconnect.spec.ts b/packages/mcp/mcp-client/tests/reconnect.spec.ts new file mode 100644 index 0000000000..f52dfdb65b --- /dev/null +++ b/packages/mcp/mcp-client/tests/reconnect.spec.ts @@ -0,0 +1,413 @@ +/** + * 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 '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 } 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.mockResolvedValue(undefined) + 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.mockRejectedValue(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) + await sleep(30) + expect(mockConnect).toHaveBeenCalledTimes(3) + }) + + 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('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('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/pnpm-lock.yaml b/pnpm-lock.yaml index 4912b264d7..abf6a6695b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -4901,6 +4901,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 From 9fcbeb86159a3f6a4bfb4d917a1110aa7163f102 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 20:21:30 +0800 Subject: [PATCH 016/113] test(web): expect read_image in shipped composition --- apps/web/tests/shipped-composition.e2e.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/web/tests/shipped-composition.e2e.ts b/apps/web/tests/shipped-composition.e2e.ts index faf01ede62..84938828a7 100644 --- a/apps/web/tests/shipped-composition.e2e.ts +++ b/apps/web/tests/shipped-composition.e2e.ts @@ -36,6 +36,7 @@ const EXPECTED_TOOLS = [ 'list_agents', 'ralph', 'read', + 'read_image', 'send_message', 'skill', 'str_replace_editor', From 0a6561fa765dbf76dcd6ae8007e87c4cfdedb0b8 Mon Sep 17 00:00:00 2001 From: creatixchu Date: Mon, 10 Aug 2026 20:36:24 +0800 Subject: [PATCH 017/113] refactor(fs): share read preflight helpers --- packages/e2b/fs-e2b/src/index.ts | 44 ++++++++++++-------------- packages/fs/tool-fs/src/read-image.ts | 11 ++----- packages/fs/tool-fs/src/read-target.ts | 34 ++++++++++++++++++++ packages/fs/tool-fs/src/read.ts | 12 ++----- 4 files changed, 58 insertions(+), 43 deletions(-) create mode 100644 packages/fs/tool-fs/src/read-target.ts diff --git a/packages/e2b/fs-e2b/src/index.ts b/packages/e2b/fs-e2b/src/index.ts index eda6866116..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: @@ -233,18 +251,7 @@ export class E2BFileSystem extends FileSystem { 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') } - let stream: ReadableStream - try { - // Same pinned-SDK quirk as streamText: 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 reader = stream.getReader() const chunks: Uint8Array[] = [] let bytes = 0 @@ -288,18 +295,7 @@ export class E2BFileSystem extends FileSystem { 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 { diff --git a/packages/fs/tool-fs/src/read-image.ts b/packages/fs/tool-fs/src/read-image.ts index f4b4d6e72f..61fe7325d7 100644 --- a/packages/fs/tool-fs/src/read-image.ts +++ b/packages/fs/tool-fs/src/read-image.ts @@ -19,9 +19,8 @@ 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 { FsError } from '@deepseek-ai/dsh-fs' import type {} from '@deepseek-ai/dsh-fs' -import { sessionResolveOptions } from './session-cwd.ts' +import { resolveRegularReadTarget } from './read-target.ts' /** Extensions `read_image` accepts; magic-byte validation at the attachment service stays authoritative. */ const IMAGE_EXTENSIONS: Readonly> = { @@ -179,13 +178,7 @@ export function applyReadImageTool(ctx: Context): void { } await assertImageCapableRoute(ctx, exec, args.file_path) - const target = await ctx.fs.resolve(args.file_path, sessionResolveOptions(exec, args.file_path)) - 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, args.file_path) // The tool result is one message carrying one image, so the per-message // aggregate bound applies beside the per-image bound. 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..02c83b35dc --- /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 '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..acf07b85ef 100644 --- a/packages/fs/tool-fs/src/read.ts +++ b/packages/fs/tool-fs/src/read.ts @@ -7,11 +7,10 @@ import type { Context } from '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. From d9d2b11b9f1483f5d3af9f79edb2b81129ad3537 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 21:03:12 +0800 Subject: [PATCH 018/113] =?UTF-8?q?wip(web):=20agent=20preset=20UI=20flow?= =?UTF-8?q?=20=E2=80=94=20creator=20intro,=20custom=20group,=20subagent=20?= =?UTF-8?q?flash,=20chrome=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/client/AgentPresetLabel.tsx | 4 +- .../src/client/AgentPresetSeat.module.css | 53 ++++ .../src/client/AgentPresetSeat.tsx | 65 ++++- .../src/client/AgentPresetSection.module.css | 12 +- .../src/client/AgentPresetSection.tsx | 237 +++++++++--------- .../ui-agent-preset/src/client/index.ts | 5 +- .../ui-agent-preset/src/client/seat-store.ts | 20 +- .../ui-agent-preset/tests/components.spec.tsx | 7 +- .../src/client/skeleton/PermissionSelect.tsx | 8 +- .../client/ui-primitives/src/icons/index.tsx | 29 +++ .../src/client/SettingsRoot.module.css | 6 +- .../ui-settings/src/client/SettingsRoot.tsx | 4 +- .../src/client/SidebarRoot.module.css | 8 +- .../src/client/SubagentCatalogAction.tsx | 8 +- .../tests/conversation-ui.spec.tsx | 19 +- .../src/client/WorkspaceBrowser.module.css | 5 +- 16 files changed, 330 insertions(+), 160 deletions(-) diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx index 517a856e9a..fb4b56490c 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..93d9f2b6fe 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -36,6 +36,59 @@ color: var(--dsw-alias-label-primary); } +/* Introduce cue: the icon eases in on an overshoot-free expo curve, 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.6s 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..84734dccfc 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,8 +32,17 @@ 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 name's characters start + fading up once the icon has mostly landed, one every stagger tick, each + taking the fade duration to settle. The cue clears after the last one. */ +const INTRO_TEXT_DELAY_MS = 300 +const INTRO_CHAR_STAGGER_MS = 60 +const INTRO_CHAR_FADE_MS = 400 + /** Full component props. */ export type AgentPresetSeatProps = PropsRuntime<'conversation.hero.agentPreset'> @@ -45,7 +54,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 +62,52 @@ 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 * INTRO_CHAR_STAGGER_MS + 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 shownLabel = introducing + ? ( + + {Array.from(label).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..79dca43f7b 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css @@ -363,6 +363,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 +373,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..4580f436bc 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSection.tsx @@ -171,6 +171,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 +204,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 + {text.description ?? t('noDescription')} + {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/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/tests/components.spec.tsx b/packages/client/ui-agent-preset/tests/components.spec.tsx index 8a37a7af43..b63b9ce63c 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({permissionGlyph(currentValue)} )} {current === undefined ? displayName(currentValue) : optionLabel(current)} - {/* Same glyph + open rotation as the sibling ModelSelect trigger. */} - - - + {/* Same glyph + open rotation as the sibling ModelSelect trigger; + class on the svg itself — an inline wrapper span leaves + baseline descent under the icon and floats it off-center. */} + } /> diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 02f4913751..354adc4454 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. The three node interiors knock out to transparency via mask so the glyph sits on any background. */ +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-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 9163e68ba8..04e8c98cd6 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -73,7 +73,7 @@ z-index: 1; display: flex; width: 800px; - height: min(800px, calc(100vh - 48px)); + height: min(824px, calc(100vh - 48px)); max-width: calc(100vw - 48px); border-radius: 24px; overflow: hidden; @@ -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-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-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/tests/conversation-ui.spec.tsx b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx index c300e423e6..ae6c0f07ee 100644 --- a/packages/client/ui-subagent/tests/conversation-ui.spec.tsx +++ b/packages/client/ui-subagent/tests/conversation-ui.spec.tsx @@ -522,22 +522,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/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; From a95e3265f6b596f2cec47b594c7ff967004c2fd9 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 21:41:32 +0800 Subject: [PATCH 019/113] fix(web): polish preset chrome and subagent menu after review --- .../ui-agent-preset/src/client/AgentPresetLabel.module.css | 2 +- .../client/ui-agent-preset/src/client/AgentPresetLabel.tsx | 2 +- .../src/client/AgentPresetSection.module.css | 6 ++++++ .../src/client/skeleton/ConversationRoot.module.css | 1 + .../client/ui-settings/src/client/SettingsRoot.module.css | 2 +- .../ui-subagent/src/client/SubagentCatalogAction.module.css | 1 - 6 files changed, 10 insertions(+), 4 deletions(-) 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 fb4b56490c..3e98310cca 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetLabel.tsx @@ -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/AgentPresetSection.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSection.module.css index 79dca43f7b..0438e23b17 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; 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..040e656bd8 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -292,6 +292,7 @@ .heroWorkspaceRow { display: flex; align-items: center; + gap: 2px; min-width: 0; /* figma drew px 8; nudged +12 so the chip's folder glyph lines up closer to the card's inner controls below. */ diff --git a/packages/client/ui-settings/src/client/SettingsRoot.module.css b/packages/client/ui-settings/src/client/SettingsRoot.module.css index 04e8c98cd6..f1bd87e9af 100644 --- a/packages/client/ui-settings/src/client/SettingsRoot.module.css +++ b/packages/client/ui-settings/src/client/SettingsRoot.module.css @@ -73,7 +73,7 @@ z-index: 1; display: flex; width: 800px; - height: min(824px, calc(100vh - 48px)); + height: min(800px, calc(100vh - 48px)); max-width: calc(100vw - 48px); border-radius: 24px; overflow: hidden; 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); From 4bee3f73ef887f35825fc8b155a0d6e36646d1f1 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 21:49:45 +0800 Subject: [PATCH 020/113] fix(web): tighten hero row spacing and round the hero chips --- .../ui-agent-preset/src/client/AgentPresetSeat.module.css | 2 +- .../src/client/skeleton/ConversationRoot.module.css | 6 ++++-- .../src/client/skeleton/HeroShell.module.css | 2 +- 3 files changed, 6 insertions(+), 4 deletions(-) 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 93d9f2b6fe..0763ffff02 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; 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 040e656bd8..932fd8e4e8 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 + 6 above the card (its margin-top restores 12 under the hero chrome). */ + gap: 6px; /* 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 @@ -294,6 +295,7 @@ align-items: center; gap: 2px; min-width: 0; + margin-top: 6px; /* 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/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; From 021ecb53c5b3781d959fb4ab8195cf13c2b463be Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 21:52:23 +0800 Subject: [PATCH 021/113] fix(web): set hero row-to-card spacing to 8 --- .../src/client/skeleton/ConversationRoot.module.css | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 932fd8e4e8..971661cd48 100644 --- a/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css +++ b/packages/client/ui-conversation/src/client/skeleton/ConversationRoot.module.css @@ -264,8 +264,8 @@ position: relative; /* .heroGlow positioning context */ align-self: center; /* figma 75:8208 drew 12 between all three rows; the workspace row now sits - 6 above the card (its margin-top restores 12 under the hero chrome). */ - gap: 6px; + 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 @@ -295,7 +295,7 @@ align-items: center; gap: 2px; min-width: 0; - margin-top: 6px; + 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; From 4f23fa84ccc8c014050bd0fde221ecf990282ce3 Mon Sep 17 00:00:00 2001 From: Hypatia May Date: Mon, 10 Aug 2026 22:19:14 +0800 Subject: [PATCH 022/113] fix(subagent): complete output selection contract --- ...nt-empty-terminal-message-output.i18n.yaml | 4 +-- ...-subagent-empty-terminal-message-output.md | 16 +++++---- ...bagent-empty-terminal-message-output.zh.md | 16 +++++---- docs/subsystems/subagent.i18n.yaml | 4 +-- docs/subsystems/subagent.md | 8 ++--- docs/subsystems/subagent.zh.md | 8 ++--- examples/acp-agent/tests/acp.snapshot.ts | 9 ++--- .../scaffold/client/tests/fake-runtime.ts | 10 +++--- packages/scaffold/protocol/README.i18n.yaml | 4 +-- packages/scaffold/protocol/README.md | 2 +- packages/scaffold/protocol/README.zh.md | 2 +- packages/scaffold/protocol/src/types.ts | 2 +- .../server/tests/built-scope-carrier.e2e.ts | 4 +-- packages/scaffold/server/tests/server.spec.ts | 4 +-- packages/subagent/subagent-acp/src/run.ts | 5 ++- .../subagent-dsh-sdk/README.i18n.yaml | 4 +-- packages/subagent/subagent-dsh-sdk/README.md | 2 +- .../subagent/subagent-dsh-sdk/README.zh.md | 2 +- .../tests/subagent-dsh-sdk.spec.ts | 2 +- .../subagent-inprocess/README.i18n.yaml | 4 +-- .../subagent/subagent-inprocess/README.md | 2 +- .../subagent/subagent-inprocess/README.zh.md | 2 +- .../tests/subagent-inprocess.spec.ts | 6 ++-- packages/subagent/subagent/README.i18n.yaml | 4 +-- packages/subagent/subagent/README.md | 2 +- packages/subagent/subagent/README.zh.md | 2 +- .../subagent/subagent/src/assistant-output.ts | 19 +++++----- packages/subagent/subagent/src/lifecycle.ts | 3 +- packages/subagent/subagent/src/types.ts | 8 ++--- .../subagent/tests/assistant-output.spec.ts | 35 ++++++++++++++++--- .../subagent/tests/continuation.spec.ts | 7 ++-- .../subagent/subagent/tests/service.spec.ts | 4 +-- 32 files changed, 113 insertions(+), 93 deletions(-) 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 index 537cb88062..612916a290 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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: d90047c07a300a1afbc42c7db1a4fefa25d56764 -2026-08-10-subagent-empty-terminal-message-output.zh.md: 0a5ce02dccef422dc75bc980d104f41f116427f2 +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 index d90047c07a..693013f681 100644 --- 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 @@ -6,24 +6,26 @@ 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 (`BlockAssembler.blocks()` drops truncated tool calls): the message exists solely to host usage. Three consumers each selected "the child's answer" with their own rule and all treated that usage host as the answer. The in-process driver's `readResult` and the continuable Activation's `subagent/end` capture took the LAST `assistant/message` unfiltered, and the SDK backend's observer let any `assistant/message` beat its streamed-text fallback. In a multi-step turn cut off at max-tokens, the final empty message therefore erased the real partial answer: `SubagentResult.output` came back `[]`, and the tool result, telemetry, and `subagent/end.lastAssistantMessage` all saw nothing. The in-process driver additionally had no streamed-text fallback at all, so a cancelled child whose only text lived in `assistant/chunk` events also reported `[]`. +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`: the last NON-EMPTY assistant message wins; without one, the accumulated `text-delta` stream is the answer; empty-content messages never contribute. The rule has one implementation, the incremental `AssistantOutputFold` (`push(event)` for session-event transports, `pushText(text)` for chunk-only transports, `collect()` to select), and `finalAssistantOutput(events)` applies it to a complete event suffix (the in-process `readResult` and the Activation capture). The SDK backend folds notification events; the ACP backend, which surfaces no complete assistant messages, folds raw chunk text into the same streamed fallback. The contract is stated once at `SubagentResult.output` and mirrored by the subsystem reference; `subagent/end.lastAssistantMessage` selects by the same rule, and "no output" has one encoding on that edge — the field is absent, never an empty array, on both the one-shot and continuable lifecycle shapes. A `max-tokens` or `aborted` finish still reports its honest stop reason; only output selection changed. +`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 observes the same selection: a non-`completed` result stays an `isError` tool result, but its message appends the child's preserved partial text after the stop-reason headline, so the parent model sees the truncated answer instead of a bare failure. +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. -The fake SDK runtime gained a `FAKE_EMPTY_MESSAGE` mode so the keyless backend test can script a usage-only terminal message, and the authored `subagent-max-tokens-partial` ACP snapshot scenario pins the assembled transcript: a scripted child streams text plus a tool call, is cut off by a tool-only max-tokens step (the empty usage-only message appears in its committed log), and the parent's tool result carries the partial answer. +## 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: the defect existed precisely because three hand-rolled selections drifted; observers of one run must agree on its answer, so the rule needs one implementation (the drafts that first proved the defect, PR #1140 and PR #1141, patched two of the three call sites separately and left the Activation capture inconsistent). +**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 is the usage host and the step's durable record ("model-visible ⟺ logged"); reshaping session events for a consumer-side selection bug would touch every replay and projection consumer. +**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 keep the text streamed before the abort; one-shot and continuable `subagent/end` edges agree with `SubagentResult.output`. A message whose content is non-empty but textless (for example reasoning-only) still wins over streamed text — the rule is about empty content, not text presence. A non-empty message also wins over text streamed AFTER it: a child cancelled while streaming a later step reports its earlier complete message, matching the SDK backend's documented contract, with the stop reason signalling the truncation. Regression tests in all three packages script the empty-terminal-message and cancel paths and fail under the previous selections. +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 index 0a5ce02dcc..64d61af21f 100644 --- 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 @@ -6,24 +6,26 @@ Status: implemented ## 问题 -当 `max-tokens` 步骤只组装了工具调用块时(`BlockAssembler.blocks()` 会丢弃被截断的工具调用),agent loop 会追加一条内容为**空**的 `assistant/message`——这条消息仅用于承载 usage。三个消费方各自用自己的规则选取"子代理的回答",并且都把这个 usage 宿主当成了回答:进程内驱动的 `readResult` 和 continuable Activation 的 `subagent/end` capture 不加过滤地取**最后一条** `assistant/message`,SDK 后端的观察器则让任何 `assistant/message` 覆盖其流式文本兜底。于是在被 max-tokens 截断的多步回合中,最后那条空消息抹掉了真实的部分回答:`SubagentResult.output` 返回 `[]`,工具结果、遥测和 `subagent/end.lastAssistantMessage` 全都看不到任何内容。此外进程内驱动完全没有流式文本兜底,因此被取消的子代理若其唯一文本只存在于 `assistant/chunk` 事件中,也会报告 `[]`。 +当 `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` 按同一规则选取,且"无输出"在该边沿只有一种编码——字段缺省,绝不是空数组,一次性与 continuable 两种生命周期形态一致。`max-tokens` 或 `aborted` 终止仍然如实上报其终止原因;只有输出选取发生了变化。 +`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` 工具结果,但其消息在终止原因标题之后附带子代理保留下来的部分文本,父模型看到的是被截断的回答而不是一句干巴巴的失败。 +前台委派工具使用同一选取规则。非 `completed` 的结果仍是 `isError` 工具结果,但其消息会在终止原因标题之后附上子 agent 的部分文本,让父模型同时接收失败信息与已有输出。 -fake SDK runtime 新增 `FAKE_EMPTY_MESSAGE` 模式,使无密钥后端测试能够脚本化一条仅承载 usage 的终止消息;authored 的 `subagent-max-tokens-partial` ACP snapshot 场景钉住了组装后的 transcript:脚本化的子代理先流式输出文本和一次工具调用,再被仅含工具调用的 max-tokens 步骤截断(空的 usage-only 消息出现在其提交的日志中),父侧工具结果携带部分回答。 +## 验证 + +无密钥 SDK 后端测试使用 `FAKE_EMPTY_MESSAGE` 发出一条仅记录 usage 的终止消息。`subagent-max-tokens-partial` ACP 快照记录一个子 agent:它流式输出文本与一次工具调用,结束于仅含工具调用的 max-tokens 步骤,持久化日志中含一条空的 usage 消息,并通过父侧的错误工具结果返回部分文本。单元覆盖检查空终止消息、取消、消息顺序、不含文本的非空消息,以及排除工具结果内容。 ## 考虑过的替代方案 -**各消费方就地修复、不抽共享辅助函数。** 之所以否决:缺陷恰恰源于三处手写选取的漂移;同一次运行的观察方必须对其回答达成一致,因此规则需要唯一实现(最早证明该缺陷的草稿 PR #1140 与 PR #1141 分别修补了三处调用点中的两处,留下 Activation capture 不一致)。 +**各消费方就地修复、不抽共享辅助函数。** 之所以否决:三处独立选取已发生分歧,而同一次运行的观察方必须对其输出达成一致。 -**让 loop 不再追加空消息。** 之所以否决:这条消息是 usage 宿主,也是该步骤的持久化记录("model-visible ⟺ logged");为一个消费方侧的选取缺陷重塑会话事件,会波及所有 replay 与 projection 消费方。 +**让 loop 不再追加空消息。** 之所以否决:这条消息记录 usage,并在持久化日志中保留该步骤("model-visible ⟺ logged");为处理输出选取而改动会话事件,会影响所有 replay 与 projection 消费方。 **把空内容消息视为错误。** 之所以否决:流式文本才是子代理真实的部分回答,且终止原因已经告诉消费方轮次被截断。 ## 后果 -被 max-tokens 截断的多步子代理会报告其更早的文本;被取消的进程内子代理保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 边沿与 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning)仍然优先于流式文本——规则针对的是内容为空,而非文本缺失。非空消息同样优先于**其后**才流式出的文本:子代理在流式后续步骤时被取消,报告的是更早那条完整消息,与 SDK 后端文档化的契约一致,截断由终止原因示意。三个包中的回归测试脚本化了空终止消息与取消路径,并在先前的选取实现下失败。 +被 max-tokens 截断的多步子 agent 会报告其更早的文本;被取消的进程内子 agent 保留中止前已流式的文本;一次性与 continuable 的 `subagent/end` 事件同 `SubagentResult.output` 一致。内容非空但不含文本的消息(例如仅含 reasoning 的内容)仍然优先于流式文本,因为规则检查内容长度,而不是文本是否存在。非空消息同样优先于其后才流式出的文本:子 agent 在流式输出后续步骤时被取消,报告的是更早那条完整消息,终止原因则记录该截断。 diff --git a/docs/subsystems/subagent.i18n.yaml b/docs/subsystems/subagent.i18n.yaml index 0e8c73e9c5..d1d97c5515 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: d04e63246f2cd3f35792905574425b3013910b8a -subagent.zh.md: 61f7330988820ddf86e7d2f8b1acc92b434768e6 +subagent.md: 273745057a750a0cdbd9c829e3922ed43398b861 +subagent.zh.md: 3d1bed59bee17900bc04156fb8bb854cab6ca1e0 diff --git a/docs/subsystems/subagent.md b/docs/subsystems/subagent.md index d04e63246f..273745057a 100644 --- a/docs/subsystems/subagent.md +++ b/docs/subsystems/subagent.md @@ -294,10 +294,10 @@ The outcome of a one-shot run, resolved by `SubagentRun.result`. `structured` is */ interface SubagentResult { /** - * The child's final assistant output: the content of the last NON-EMPTY - * assistant message (an empty-content message hosts only usage and is - * skipped), else the text streamed before the turn was cut short, or `[]` - * when the child produced none. + * 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[] /** diff --git a/docs/subsystems/subagent.zh.md b/docs/subsystems/subagent.zh.md index 61f7330988..3d1bed59be 100644 --- a/docs/subsystems/subagent.zh.md +++ b/docs/subsystems/subagent.zh.md @@ -294,10 +294,10 @@ type SubagentDescendantListEntry = SubagentListEntry & { */ interface SubagentResult { /** - * The child's final assistant output: the content of the last NON-EMPTY - * assistant message (an empty-content message hosts only usage and is - * skipped), else the text streamed before the turn was cut short, or `[]` - * when the child produced none. + * 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[] /** diff --git a/examples/acp-agent/tests/acp.snapshot.ts b/examples/acp-agent/tests/acp.snapshot.ts index 8cf0deeb1e..f06bf1fbf4 100644 --- a/examples/acp-agent/tests/acp.snapshot.ts +++ b/examples/acp-agent/tests/acp.snapshot.ts @@ -315,12 +315,9 @@ 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 (like error-finish): a live child cannot be coaxed into - // a max-tokens step that assembled ONLY tool-call blocks — the truncation - // shape whose usage-only empty assistant/message must not erase the child's - // earlier text. The child fixture scripts text + todo_write, then a - // tool-only max-tokens cutoff; the parent's subagent tool result must carry - // the child's real partial answer with the max-tokens stop reason. + // 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 }, { name: 'subagent-fork', hasModelTurn: true, recorded: true }, diff --git a/packages/scaffold/client/tests/fake-runtime.ts b/packages/scaffold/client/tests/fake-runtime.ts index 0462fabf4a..4fb2b6017a 100644 --- a/packages/scaffold/client/tests/fake-runtime.ts +++ b/packages/scaffold/client/tests/fake-runtime.ts @@ -26,9 +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's assistant/message has EMPTY content (a - * usage-only max-tokens step) after streaming the text chunk — a consumer - * must keep the streamed text instead of the empty message. + * - `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 @@ -120,9 +119,8 @@ function runTurn(sessionId: string): void { message: { id: `fake-assistant-${seq}`, role: 'assistant', - // FAKE_EMPTY_MESSAGE: a usage-only terminal message (the harness loop - // appends one when a max-tokens step assembled no text blocks) whose - // empty content must not erase the text streamed above. + // 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/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/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/server/tests/built-scope-carrier.e2e.ts b/packages/scaffold/server/tests/built-scope-carrier.e2e.ts index fdd5276352..a51c88ddb3 100644 --- a/packages/scaffold/server/tests/built-scope-carrier.e2e.ts +++ b/packages/scaffold/server/tests/built-scope-carrier.e2e.ts @@ -106,8 +106,8 @@ describe.skipIf(!existsSync(jsonrpcBundle))('dsh-jsonrpc BUILT scope carrier', ( }) expect(stderr).not.toContain('listener threw') - // A childless result carries NO lastAssistantMessage on the wire: the end - // edge encodes "no output" as an absent field, never `[]`. + // A result without output omits lastAssistantMessage from the wire; it + // never sends `[]`. expect(JSON.parse(stdout) as unknown).toEqual([{ method: 'subagent.finished', params: { diff --git a/packages/scaffold/server/tests/server.spec.ts b/packages/scaffold/server/tests/server.spec.ts index 7b375f1a97..bdad715d4f 100644 --- a/packages/scaffold/server/tests/server.spec.ts +++ b/packages/scaffold/server/tests/server.spec.ts @@ -736,8 +736,8 @@ describe('HarnessSdkServer', () => { stopReason: 'error', }) - // A childless result carries NO lastAssistantMessage on the wire: the - // end edge encodes "no output" as an absent field, never `[]`. + // A result without output omits lastAssistantMessage from the wire; it + // never sends `[]`. expect(transport.notifications).toContainEqual({ method: 'subagent.finished', params: { diff --git a/packages/subagent/subagent-acp/src/run.ts b/packages/subagent/subagent-acp/src/run.ts index e5c0249433..38329244ba 100644 --- a/packages/subagent/subagent-acp/src/run.ts +++ b/packages/subagent/subagent-acp/src/run.ts @@ -233,9 +233,8 @@ export async function startAcpRun(request: SubagentStartRequest, spec: AcpRunSpe let processDisposal: Promise | undefined const disposeProcess = (): Promise => (processDisposal ??= disposeAcpChild(child, spec.disposeEofGraceMs)) - // The child's streamed assistant text, accumulated under the seam's - // canonical selection rule (`AssistantOutputFold`); ACP surfaces no complete - // assistant messages, so only the streamed-fallback half applies. + // 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 } diff --git a/packages/subagent/subagent-dsh-sdk/README.i18n.yaml b/packages/subagent/subagent-dsh-sdk/README.i18n.yaml index cbe3becb25..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: 80f5c40a2c949b7c2a638ec19c02950e8cd69f0b -README.zh.md: b34421dbbf2d06b7c9236776aabf19ba8005204e +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 80f5c40a2c..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 NON-EMPTY `assistant/message` (an empty-content message hosts only usage and is skipped), 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 b34421dbbf..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`(空内容消息仅承载 usage,会被跳过),或该活动中断前已经累积的 `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/tests/subagent-dsh-sdk.spec.ts b/packages/subagent/subagent-dsh-sdk/tests/subagent-dsh-sdk.spec.ts index c00dfe5fec..da95c5a977 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 @@ -176,7 +176,7 @@ 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 () => { + 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 diff --git a/packages/subagent/subagent-inprocess/README.i18n.yaml b/packages/subagent/subagent-inprocess/README.i18n.yaml index ed5606cbc9..4a39ea60f9 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: 2e2a3873843467b0811ccbc0ed1d9bb6a83eb31f -README.zh.md: 91013164e762bb01e7ad5a51597c6fa559c8d7a3 +README.md: fd5129b044b3d9008c3ba73645f6de36ddbf35dc +README.zh.md: 8f7ff137f04183b50acbe96bcd20a4023dfb86f4 diff --git a/packages/subagent/subagent-inprocess/README.md b/packages/subagent/subagent-inprocess/README.md index 2e2a387384..fd5129b044 100644 --- a/packages/subagent/subagent-inprocess/README.md +++ b/packages/subagent/subagent-inprocess/README.md @@ -14,7 +14,7 @@ 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 output — its last NON-EMPTY assistant message (an empty-content message hosts only usage and is skipped), else the text it streamed before cancel or truncation cut the turn short — and the 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. diff --git a/packages/subagent/subagent-inprocess/README.zh.md b/packages/subagent/subagent-inprocess/README.zh.md index 91013164e7..8f7ff137f0 100644 --- a/packages/subagent/subagent-inprocess/README.zh.md +++ b/packages/subagent/subagent-inprocess/README.zh.md @@ -14,7 +14,7 @@ 2. 直接调用 `parent.ctx.agents.create`,把必需的请求信号传入工厂的创建事务。 3. 在该事务未发布的设置窗口中,安装请求的 persona、工具限制和结构化输出运行时。 4. 发布子 agent,保留返回的 `AgentHandle`,并通过先调用 `child.followup(prompt)`、再调用 `child.whenIdle()` 来驱动一项任务。 -5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条**非空** assistant 消息(空内容消息仅承载 usage,会被跳过),否则取轮次被取消或截断前已流式的文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。 +5. 从完整的自有子运行中读取子 agent 自身的输出——最后一条非空 assistant 消息(记录 usage 的空内容消息会被跳过),若没有这类消息则取其累积的 assistant 文本——以及最终持久化的轮次原因,并排除任何 fork 初始内容。 子 agent 会获得父 agent 的工作目录/会话谱系;除非 `request.agentOptions` 覆盖,否则还会继承父 agent 的提供方、模型和输出 token 上限。它获得全新的扁平注册作用域:父级所有权不会导入父 agent 的工具限制,也不会建立权限子集。 diff --git a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts index ebbc893b03..ef92dea15a 100644 --- a/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts +++ b/packages/subagent/subagent-inprocess/tests/subagent-inprocess.spec.ts @@ -157,10 +157,8 @@ describe('startInProcessRun', () => { }) it('keeps earlier streamed text when the final step appends an empty usage-only message', async () => { - // Step 1 streams "partial one" plus a tool call; step 2 hits max-tokens - // having assembled only a tool-call block, so the loop appends an EMPTY - // assistant/message to host usage. The empty message is not assistant - // output and must not erase step 1's text from the run's output. + // 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'), [ diff --git a/packages/subagent/subagent/README.i18n.yaml b/packages/subagent/subagent/README.i18n.yaml index 531f924cec..6166075110 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: 3bc5bc1c07077f21a4245c3ce08470bc976c1458 -README.zh.md: 90fde6b4403891edf910c45327c9b0f629980303 +README.md: b9757bb04609d3cdd1459d5845e5c59388b269f6 +README.zh.md: 9d28d057e08ddb12530ba2894eda8046a5c0ff5a diff --git a/packages/subagent/subagent/README.md b/packages/subagent/subagent/README.md index 3bc5bc1c07..b9757bb046 100644 --- a/packages/subagent/subagent/README.md +++ b/packages/subagent/subagent/README.md @@ -60,7 +60,7 @@ The seam owns the depth vocabulary shared by Service providers and Consumers: th `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. `output` and the `subagent/end` edge's `lastAssistantMessage` share one selection rule, implemented once by the exported `AssistantOutputFold`/`finalAssistantOutput` helpers: the child's last non-empty assistant message, else the text it streamed before the turn was cut short ([`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) owns the contract). +`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. diff --git a/packages/subagent/subagent/README.zh.md b/packages/subagent/subagent/README.zh.md index 90fde6b440..9d28d057e0 100644 --- a/packages/subagent/subagent/README.zh.md +++ b/packages/subagent/subagent/README.zh.md @@ -60,7 +60,7 @@ subagent seam 允许一个 agent(智能体)通过具名提供方把工作委 `provider.start(request): Promise` 是所有权转移边界;委派工具也会在其由 Task 支撑的一次性后台路径中使用它。兑现前,提供方拥有设置过程,并且每次失败时都必须取消、回滚并使未发布资源完全停稳。兑现后,调用方拥有该运行,并且必须在每条路径上调用 `dispose()`;剩余提示词和轮次工作属于 `SubagentRun.result`。 -`SubagentRun.result` 兑现为 `{ output, structured?, stopReason }`。子 agent 级失败会以非 `completed` 原因兑现;只有 seam 无法表示的基础设施故障才可以拒绝。`dispose()` 是幂等的,会取消剩余工作,并等待结果结算以及子 agent 资源完全停稳。`result` 的 rejection 仍归 `result` 通道;只有独立的资源释放失败会使 `dispose()` 拒绝。`output` 与 `subagent/end` 边沿的 `lastAssistantMessage` 共用同一条选取规则,由导出的 `AssistantOutputFold`/`finalAssistantOutput` 辅助函数唯一实现:取子 agent 最后一条非空 assistant 消息,否则取轮次被截断前已流式的文本(契约归 [`SubagentResult.output`](../../../docs/subsystems/subagent.md#the-terminal-result-subagentresult) 所有)。 +`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 会话,其一次性运行不会进入基于追踪的枚举结果。 diff --git a/packages/subagent/subagent/src/assistant-output.ts b/packages/subagent/subagent/src/assistant-output.ts index a617060390..6701327cfd 100644 --- a/packages/subagent/subagent/src/assistant-output.ts +++ b/packages/subagent/subagent/src/assistant-output.ts @@ -1,12 +1,11 @@ /** - * Canonical selection of a child's final assistant output. Every surface that - * reports "the child's answer" — backend run results and - * `subagent/end.lastAssistantMessage` — applies this one rule so observers - * agree: the last NON-EMPTY assistant message wins; an empty-content message - * hosts only usage (the loop appends one when a max-tokens step assembled no - * executable blocks) and never erases real output; without any non-empty - * message, the text streamed so far is the answer (a partial surviving - * cancel, error, and truncation paths). + * 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 */ @@ -35,7 +34,7 @@ export class AssistantOutputFold { 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.partial.push(event.data.chunk.text) + this.pushText(event.data.chunk.text) } } @@ -44,7 +43,7 @@ export class AssistantOutputFold { * @param text - the next streamed text piece (an empty piece is a no-op). */ pushText(text: string): void { - this.partial.push(text) + if (text.length > 0) this.partial.push(text) } /** diff --git a/packages/subagent/subagent/src/lifecycle.ts b/packages/subagent/subagent/src/lifecycle.ts index df050f5538..b0e66ea695 100644 --- a/packages/subagent/subagent/src/lifecycle.ts +++ b/packages/subagent/subagent/src/lifecycle.ts @@ -129,8 +129,7 @@ export function observeRun( emit('subagent/end', { ...identity, stopReason: result.stopReason, - // One encoding for "no output" across both lifecycle shapes: the - // field is absent, matching the continuable epoch edge. + // Omit the field when no output exists, matching continuable epochs. ...result.output.length === 0 ? {} : { lastAssistantMessage: result.output }, }, parent) }, diff --git a/packages/subagent/subagent/src/types.ts b/packages/subagent/subagent/src/types.ts index 7004ad4ffb..63a890176e 100644 --- a/packages/subagent/subagent/src/types.ts +++ b/packages/subagent/subagent/src/types.ts @@ -218,10 +218,10 @@ export type SubagentStopReason = SubagentStopReasonMap[keyof SubagentStopReasonM */ export interface SubagentResult { /** - * The child's final assistant output: the content of the last NON-EMPTY - * assistant message (an empty-content message hosts only usage and is - * skipped), else the text streamed before the turn was cut short, or `[]` - * when the child produced none. + * 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[] /** diff --git a/packages/subagent/subagent/tests/assistant-output.spec.ts b/packages/subagent/subagent/tests/assistant-output.spec.ts index 2205431aae..3d510a94e8 100644 --- a/packages/subagent/subagent/tests/assistant-output.spec.ts +++ b/packages/subagent/subagent/tests/assistant-output.spec.ts @@ -15,6 +15,22 @@ 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 = [ @@ -25,19 +41,30 @@ describe('finalAssistantOutput', () => { expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'step two' }]) }) - it('prefers a non-empty message over the streamed text', () => { + it('prefers a non-empty message over text streamed before and after it', () => { const events = [ - textDelta('streamed '), - textDelta('text'), + textDelta('earlier partial'), message([{ type: 'text', text: 'complete answer' }]), + textDelta('later partial'), + message([]), ] expect(finalAssistantOutput(events)).toEqual([{ type: 'text', text: 'complete answer' }]) }) - it('falls back to accumulated text deltas when no non-empty message exists', () => { + 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([]), ] diff --git a/packages/subagent/subagent/tests/continuation.spec.ts b/packages/subagent/subagent/tests/continuation.spec.ts index b60ae57f99..e15b98ca5a 100644 --- a/packages/subagent/subagent/tests/continuation.spec.ts +++ b/packages/subagent/subagent/tests/continuation.spec.ts @@ -1201,10 +1201,9 @@ describe('continuable review regressions', () => { }) it('keeps the epoch\'s earlier text past a final empty usage-only message', async () => { - // Step 1 streams text plus a tool call; step 2 hits max-tokens having - // assembled only a tool-call block, so the loop appends an EMPTY - // assistant/message to host usage. The terminal edge reports the epoch's - // real answer text, not the internal usage marker. + // 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'), [ diff --git a/packages/subagent/subagent/tests/service.spec.ts b/packages/subagent/subagent/tests/service.spec.ts index b46f2489fa..af009d1ca3 100644 --- a/packages/subagent/subagent/tests/service.spec.ts +++ b/packages/subagent/subagent/tests/service.spec.ts @@ -264,8 +264,8 @@ describe('SubagentService', () => { stopReason: 'completed', })) - // "No output" has ONE encoding on the end edge: the field is absent, - // never an empty array, matching the continuable epoch edge. + // 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()) From eb298a439fa6e2ad1bf0b5c64b6ed98113309974 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 22:54:19 +0800 Subject: [PATCH 023/113] test(web): count the preset icon and keep the access chevron aria-hidden --- .../src/client/skeleton/PermissionSelect.module.css | 3 +++ .../src/client/skeleton/PermissionSelect.tsx | 8 ++++---- packages/client/ui-primitives/src/icons/index.tsx | 2 +- packages/client/ui-primitives/tests/icons.spec.tsx | 4 ++-- 4 files changed, 10 insertions(+), 7 deletions(-) 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/PermissionSelect.tsx b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx index 47bd4e274d..73f4080c1c 100644 --- a/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx +++ b/packages/client/ui-conversation/src/client/skeleton/PermissionSelect.tsx @@ -147,10 +147,10 @@ export function PermissionSelect({ value, locked, command, t }: PermissionSelect {permissionGlyph(currentValue)} )} {current === undefined ? displayName(currentValue) : optionLabel(current)} - {/* Same glyph + open rotation as the sibling ModelSelect trigger; - class on the svg itself — an inline wrapper span leaves - baseline descent under the icon and floats it off-center. */} - + {/* Same glyph + open rotation as the sibling ModelSelect trigger. */} + + + } /> diff --git a/packages/client/ui-primitives/src/icons/index.tsx b/packages/client/ui-primitives/src/icons/index.tsx index 354adc4454..972e0ec14d 100644 --- a/packages/client/ui-primitives/src/icons/index.tsx +++ b/packages/client/ui-primitives/src/icons/index.tsx @@ -349,7 +349,7 @@ export const IconThinkOutline16 = ({ size = 16, className }: IconProps) => ( ) -/** ic_ds_agent_preset_outline_16. The three node interiors knock out to transparency via mask so the glyph sits on any background. */ +/** 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) => ( 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) => { From 00708b950b5d562515377453ce856caa53f44e33 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:13:32 +0800 Subject: [PATCH 024/113] feat: direct issue status from PR review events --- .agents/notes/archived/manifest.json | 6 + ...-04-forward-only-pr-issue-status.i18n.yaml | 4 +- ...2026-08-04-forward-only-pr-issue-status.md | 1 + ...6-08-04-forward-only-pr-issue-status.zh.md | 1 + ...-driven-issue-lifecycle-triggers.i18n.yaml | 4 +- ...-review-driven-issue-lifecycle-triggers.md | 1 + ...view-driven-issue-lifecycle-triggers.zh.md | 1 + ...-event-directed-pr-review-status.i18n.yaml | 6 + ...6-08-10-event-directed-pr-review-status.md | 41 ++++++ ...8-10-event-directed-pr-review-status.zh.md | 41 ++++++ .github/issue-management/config.json | 1 + .github/issue-management/policy.mjs | 139 ++++++++++++++---- .github/issue-management/policy.test.mjs | 88 ++++++++--- .github/workflows/issue-lifecycle.yml | 1 + lefthook.yml | 4 + scripts/ci-workflow.spec.ts | 31 +++- 16 files changed, 313 insertions(+), 57 deletions(-) rename .agents/notes/{implemented => archived}/process/2026-08-04-forward-only-pr-issue-status.i18n.yaml (68%) rename .agents/notes/{implemented => archived}/process/2026-08-04-forward-only-pr-issue-status.md (99%) rename .agents/notes/{implemented => archived}/process/2026-08-04-forward-only-pr-issue-status.zh.md (99%) rename .agents/notes/{implemented => archived}/process/2026-08-08-review-driven-issue-lifecycle-triggers.i18n.yaml (66%) rename .agents/notes/{implemented => archived}/process/2026-08-08-review-driven-issue-lifecycle-triggers.md (99%) rename .agents/notes/{implemented => archived}/process/2026-08-08-review-driven-issue-lifecycle-triggers.zh.md (99%) create mode 100644 .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.md create mode 100644 .agents/notes/implemented/process/2026-08-10-event-directed-pr-review-status.zh.md diff --git a/.agents/notes/archived/manifest.json b/.agents/notes/archived/manifest.json index 638d87373d..f1613ab0d6 100644 --- a/.agents/notes/archived/manifest.json +++ b/.agents/notes/archived/manifest.json @@ -301,6 +301,12 @@ "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-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-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/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/.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/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index 7a25b5223d..300d8e4bfa 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.review.state == 'changes_requested' }} runs-on: ubuntu-latest steps: - name: Check out trusted policy diff --git a/lefthook.yml b/lefthook.yml index 0ea7f4e537..7ed1fcb886 100644 --- a/lefthook.yml +++ b/lefthook.yml @@ -6,6 +6,8 @@ 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: lint (staged) @@ -35,6 +37,8 @@ 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} pre-push: diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index 2269c7cbee..a67dc0818a 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -113,20 +113,40 @@ 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.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 = hook.jobs.find(job => 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 +160,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) } From 9e8cd1acfb94613f121cfd105fd36a26e6a0d406 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 23:14:27 +0800 Subject: [PATCH 025/113] test(web): re-record the preset section golden and borderless menu inset --- .../tests/snapshots/agent-preset-authoring/section.expected.md | 1 + apps/web/tests/subagent-conversation.e2e.ts | 3 ++- 2 files changed, 3 insertions(+), 1 deletion(-) 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..9f87471f02 100644 --- a/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md +++ b/apps/web/tests/snapshots/agent-preset-authoring/section.expected.md @@ -58,6 +58,7 @@ - 'button "复制: 创造模式"': - img - text: 复制 + - heading "自定义" [level=3] - button "用「创造模式」创作自定义预设": - img - text: 用「创造模式」创作自定义预设 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), From a8fae974c2be649633990080f7060421dfeb0213 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 23:19:23 +0800 Subject: [PATCH 026/113] test(web): the custom group heading outlives its last preset --- apps/web/tests/agent-preset-authoring.e2e.ts | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/apps/web/tests/agent-preset-authoring.e2e.ts b/apps/web/tests/agent-preset-authoring.e2e.ts index e8ff5aa538..6a13b791c5 100644 --- a/apps/web/tests/agent-preset-authoring.e2e.ts +++ b/apps/web/tests/agent-preset-authoring.e2e.ts @@ -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) From 3716459223f7f23a78639b35da608141fb1f95b9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:35:05 +0800 Subject: [PATCH 027/113] fix(ci): narrow issue lifecycle review events --- .github/workflows/issue-lifecycle.yml | 2 +- scripts/ci-workflow.spec.ts | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/issue-lifecycle.yml b/.github/workflows/issue-lifecycle.yml index 300d8e4bfa..e324cfefc2 100644 --- a/.github/workflows/issue-lifecycle.yml +++ b/.github/workflows/issue-lifecycle.yml @@ -36,7 +36,7 @@ concurrency: jobs: lifecycle: name: Issue lifecycle - if: ${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }} + 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/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index a67dc0818a..db5ea9a0fa 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -125,7 +125,7 @@ describe('Issue lifecycle workflow', () => { expect(lifecyclePullRequest.types).toContain('review_requested') expect(lifecycleReview.types).toEqual(['submitted']) expect(lifecycleJob.if).toBe( - "${{ github.event_name != 'pull_request_review' || github.event.review.state == 'changes_requested' }}", + "${{ github.event_name != 'pull_request_review' || (github.event.action == 'submitted' && github.event.review.state == 'changes_requested') }}", ) expect(policyPullRequest.types).toContain('ready_for_review') }) From e9a3a388736700f19e3a4ec9d21b53de58aad47f Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:55:27 +0800 Subject: [PATCH 028/113] fix(ci): type workflow fixture search safely --- scripts/ci-workflow.spec.ts | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/scripts/ci-workflow.spec.ts b/scripts/ci-workflow.spec.ts index db5ea9a0fa..2e4df208f4 100644 --- a/scripts/ci-workflow.spec.ts +++ b/scripts/ci-workflow.spec.ts @@ -140,7 +140,9 @@ describe('Git hooks', () => { if (!isRecord(hook) || !Array.isArray(hook.jobs)) { throw new TypeError(`lefthook must define ${hookName} jobs`) } - const pairing = hook.jobs.find(job => isRecord(job) && job.name === 'translation pairing (staged records)') + const pairing: unknown = hook.jobs.find( + (job: unknown) => isRecord(job) && job.name === 'translation pairing (staged records)', + ) expect(pairing).toMatchObject({ exclude: ['.agents/notes/archived/**'] }) } From 6147f0238609e0b9106ac508564c6c6ac1889b57 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:57:44 +0800 Subject: [PATCH 029/113] fix(mcp-client): await failed generation shutdown The MCP SDK starts a fire-and-forget close when initialization fails. Its stdio transport clears its process field before that close finishes, so our second Client.close() could return immediately and the reconnect timer could launch a replacement while the original child was still alive. Track the transport onclose signal for every client generation and gate failed-attempt backoff on both Client.close() settlement and that signal. Use the same barrier during plugin disposal. If the SDK's bounded stdio termination window expires without onclose, fail closed and report incomplete shutdown instead of risking overlapping server processes. Regression coverage models the SDK's early-returning second close, delayed and missing close signals, pending-connect disposal, close rejection, and the terminal timeout path. The reconnect Agent Note and Chinese counterpart now record the quiescence contract. --- ...-08-06-mcp-client-auto-reconnect.i18n.yaml | 4 +- .../2026-08-06-mcp-client-auto-reconnect.md | 4 +- ...2026-08-06-mcp-client-auto-reconnect.zh.md | 4 +- packages/mcp/mcp-client/src/connection.ts | 60 ++++++++++++- packages/mcp/mcp-client/tests/apply.spec.ts | 10 ++- .../mcp/mcp-client/tests/reconnect.spec.ts | 84 ++++++++++++++++++- 6 files changed, 153 insertions(+), 13 deletions(-) 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 index d9ec007b53..e0aac526cc 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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: b187f5de71a10ca3121d817f383a98669246c81f -2026-08-06-mcp-client-auto-reconnect.zh.md: a4ec6897c34f2142ceb95abecd13d189c4a95624 +2026-08-06-mcp-client-auto-reconnect.md: e371fa13dc7bc5330f658f4d6e864969baf93cf0 +2026-08-06-mcp-client-auto-reconnect.zh.md: eb6e67dabd7c6115b9ecb507029ffa7d45338466 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 index b187f5de71..e371fa13dc 100644 --- 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 @@ -14,7 +14,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. **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. Failure signals are idempotent per generation: a connect rejection racing its own transport close schedules exactly one retry. +**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. 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. @@ -38,7 +38,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. ## Testing -Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, 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. +Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, 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 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 index a4ec6897c3..eb6e67dabd 100644 --- 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 @@ -14,7 +14,7 @@ Status: implemented **触发条件。** 监督器在每一代上挂载 `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` 通知同时触发重新同步。失败信号按代幂等:一次连接拒绝与其自身 transport 关闭竞态时,仅调度恰好一次重试。 +**代隔离,无交错。** 每次尝试构建一个全新的 transport 和 `Client`(SDK 将一个 Protocol 绑定到一个 transport 上终身使用)。每个监督器内部有一个队列将所有 `syncTools` 调用串行化——跨所有代的初始同步和 `list_changed` 再同步——`isCurrent` 栅栏使过时的代变为惰性,从而确保不会有两次同步交错执行 dispose 上一代/注册下一代的切换(否则会对同一代执行两次 dispose 并泄漏另一代)。该队列还消除了一个先前存在的竞态:两次快速的 `list_changed` 通知同时触发重新同步。失败信号按代幂等:一次连接拒绝与其自身 transport 关闭竞态时,仅调度恰好一次重试。失败尝试只有在 `Client.close()` 结算且 transport 报告 `onclose` 后才能进入退避;对 stdio 而言,`onclose` 证明子进程已退出;若关闭信号始终未到,则在 SDK 的有界终止窗口结束后停止重连,而不是允许两个服务器进程重叠运行。dispose 使用同一个有界关闭信号屏障;若关停未完成则予以报告,且绝不重启。 **有界退避与故障预算。** 延迟从 `initialDelayMs` 起逐次翻倍,上限为 `maxDelayMs`。一次故障期间共享 `maxAttempts` 次连续失败尝试的预算;耗尽后注销该服务器的工具、以 error 级别记录日志并停止,直到 dispose 或重新加载。连接在存活超过稳定窗口——即 `maxDelayMs`,作为最长退避间隔从配置推导得出而非作为第五个独立调参项——之后重置预算;因此偶尔崩溃的服务器可无限恢复,而连接短暂成功后立即再次崩溃的循环无法将其预算洗白为重启风暴。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 +单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、初始化失败会等待旧代的关闭信号,若该信号始终未到则停止重连、dispose 同样等待同一关闭信号,并在有界等待到期时报告关停未完成、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 ## 后果 diff --git a/packages/mcp/mcp-client/src/connection.ts b/packages/mcp/mcp-client/src/connection.ts index 4006594111..2760324a20 100644 --- a/packages/mcp/mcp-client/src/connection.ts +++ b/packages/mcp/mcp-client/src/connection.ts @@ -44,6 +44,11 @@ export const RECONNECT_DEFAULTS: Required = Object.freeze({ 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> @@ -133,6 +138,8 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe 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 @@ -169,9 +176,22 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe 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 { if (!policy.enabled) { const detail = connectedAt !== undefined @@ -216,8 +236,19 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe { 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 - generation.onclose = () => { generationDown(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( @@ -236,12 +267,32 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe ) try { await generation.connect(createTransport(config)) + if (hasClosed()) { + attemptSettled = true + generationDown(generation) + return + } await enqueueSync(generation) } catch (error) { if (firstAttemptError === undefined) firstAttemptError = error - // When the transport closed first, onclose already logged and scheduled. + // 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 } @@ -277,9 +328,14 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe 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. diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 09d660001f..b6a9409c63 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -159,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, @@ -338,7 +341,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/reconnect.spec.ts b/packages/mcp/mcp-client/tests/reconnect.spec.ts index 4abbb4cbbf..6d85e753ea 100644 --- a/packages/mcp/mcp-client/tests/reconnect.spec.ts +++ b/packages/mcp/mcp-client/tests/reconnect.spec.ts @@ -59,7 +59,7 @@ vi.mock('@modelcontextprotocol/sdk/client/streamableHttp.js', () => ({ // 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 } from '@deepseek-ai/dsh-mcp-client/src/connection.ts' +import { RECONNECT_DEFAULTS, resolveReconnectPolicy, startConnection } from '@deepseek-ai/dsh-mcp-client/src/connection.ts' // ---- Helpers ---- @@ -128,7 +128,10 @@ describe('reconnect supervisor', () => { vi.clearAllMocks() instances.length = 0 mockConnect.mockResolvedValue(undefined) - mockClose.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() @@ -174,7 +177,10 @@ describe('reconnect supervisor', () => { mockConnect.mockRejectedValue(new Error('server gone')) // A failing close on the failed attempt's cleanup must not break the loop. - mockClose.mockRejectedValue(new Error('already closed')) + mockClose.mockImplementation(function (this: { onclose?: () => void }) { + this.onclose?.() + return Promise.reject(new Error('already closed')) + }) instances[0]!.onclose?.() await vi.waitFor(() => { @@ -189,6 +195,78 @@ describe('reconnect supervisor', () => { expect(mockConnect).toHaveBeenCalledTimes(3) }) + it('does not start a replacement until a failed generation reports that it closed', async () => { + 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) }) + }) + + 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() }) From cac8e1c53deb24bc0123463391046b85811aef2b Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Mon, 10 Aug 2026 23:59:53 +0800 Subject: [PATCH 030/113] fix(client): finish the preset intro inside one shared reveal window A fixed 60ms per-character tick made a Latin preset name run three times longer than its CJK counterpart. The stagger is now capped by a 200ms shared window (min(40, 200/(n-1))), the icon lands in 150ms with the characters starting the moment it does, and the whole timeline is pinned by component tests alongside the store acknowledgement and the empty custom group. --- ...0-creator-guidance-introduce-cue.i18n.yaml | 6 ++ ...26-08-10-creator-guidance-introduce-cue.md | 33 +++++++ ...08-10-creator-guidance-introduce-cue.zh.md | 33 +++++++ .../src/client/AgentPresetSeat.module.css | 10 ++- .../src/client/AgentPresetSeat.tsx | 32 +++++-- .../ui-agent-preset/tests/apply.spec.ts | 9 ++ .../ui-agent-preset/tests/components.spec.tsx | 88 +++++++++++++++++++ .../ui-agent-preset/tests/section.spec.tsx | 14 +++ 8 files changed, 213 insertions(+), 12 deletions(-) create mode 100644 .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.i18n.yaml create mode 100644 .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.md create mode 100644 .agents/notes/implemented/feature/2026-08-10-creator-guidance-introduce-cue.zh.md 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/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css index 0763ffff02..55fe22e81b 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.module.css @@ -36,11 +36,13 @@ color: var(--dsw-alias-label-primary); } -/* Introduce cue: the icon eases in on an overshoot-free expo curve, 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. */ +/* 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.6s cubic-bezier(0.16, 1, 0.3, 1) both; + animation: seat-icon-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both; } @keyframes seat-icon-in { diff --git a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx index 84734dccfc..f7350076c2 100644 --- a/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx +++ b/packages/client/ui-agent-preset/src/client/AgentPresetSeat.tsx @@ -36,13 +36,27 @@ export interface AgentPresetSeatInjected { introduced: () => void } -/* Introduce timeline: the icon eases in first; the name's characters start - fading up once the icon has mostly landed, one every stagger tick, each - taking the fade duration to settle. The cue clears after the last one. */ -const INTRO_TEXT_DELAY_MS = 300 -const INTRO_CHAR_STAGGER_MS = 60 +/* 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. */ export type AgentPresetSeatProps = PropsRuntime<'conversation.hero.agentPreset'> @@ -83,7 +97,7 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, const done = window.setTimeout(() => { setIntroducing(false) introduced() - }, INTRO_TEXT_DELAY_MS + characters.length * INTRO_CHAR_STAGGER_MS + INTRO_CHAR_FADE_MS) + }, INTRO_TEXT_DELAY_MS + (characters.length - 1) * introStaggerMs(characters.length) + INTRO_CHAR_FADE_MS) return () => { window.clearTimeout(done) } }, [state.introduce, ready, label, introduced]) @@ -93,14 +107,16 @@ export function AgentPresetSeat({ load, select, introduced, useAgentPresetSeat, // 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 ? ( - {Array.from(label).map((character, index) => ( + {characters.map((character, index) => ( {character} diff --git a/packages/client/ui-agent-preset/tests/apply.spec.ts b/packages/client/ui-agent-preset/tests/apply.spec.ts index 23e1944948..a886569037 100644 --- a/packages/client/ui-agent-preset/tests/apply.spec.ts +++ b/packages/client/ui-agent-preset/tests/apply.spec.ts @@ -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 b63b9ce63c..0c29175a60 100644 --- a/packages/client/ui-agent-preset/tests/components.spec.tsx +++ b/packages/client/ui-agent-preset/tests/components.spec.tsx @@ -277,6 +277,94 @@ describe('the new-session chip', () => { }) }) +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/section.spec.tsx b/packages/client/ui-agent-preset/tests/section.spec.tsx index 05c2b28d67..93e7fdd1e5 100644 --- a/packages/client/ui-agent-preset/tests/section.spec.tsx +++ b/packages/client/ui-agent-preset/tests/section.spec.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() From 9e5135e338c89f7b3727a8031f6ee66ca46c9cd1 Mon Sep 17 00:00:00 2001 From: Yif <877193178@qq.com> Date: Tue, 11 Aug 2026 00:00:07 +0800 Subject: [PATCH 031/113] =?UTF-8?q?fix(client):=20correct=20the=20Chinese?= =?UTF-8?q?=20hero=20slogan=20to=20=E6=8E=A2=E7=B4=A2=E6=9C=AA=E8=87=B3?= =?UTF-8?q?=E4=B9=8B=E5=A2=83?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The shipped copy read 探索未知之境; the product slogan is 探索未至之境. English copy is untouched. --- packages/client/ui-conversation/src/client/locales.ts | 2 +- packages/client/ui-conversation/tests/skeleton.spec.tsx | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) 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/tests/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 7d7596a49e..ee05eb3a3e 100644 --- a/packages/client/ui-conversation/tests/skeleton.spec.tsx +++ b/packages/client/ui-conversation/tests/skeleton.spec.tsx @@ -356,7 +356,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 +380,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 +405,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 +423,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() }) From 442f0ef839dad5cf1b6d966ddb34a85ba8dcb0c2 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:00:27 +0800 Subject: [PATCH 032/113] fix(mcp-client): bind strict sync to activation The supervisor selected strict startup registration with a shared isFirstSync flag. Because the MCP SDK may deliver tools/list_changed before connect() resolves, that notification could enter the sync queue first, consume the strict option inside its contained handler, and leave the actual activation sync non-fatal. Pass startup intent explicitly to connectGeneration(). Only the plugin activation attempt receives the failOnStartupError registration policy; notification-driven syncs and later reconnect generations always use contained runtime semantics. Queue arrival order can no longer redefine startup behavior. A regression test injects list_changed from inside connect(), keeps a foreign namespace squatter in place, and proves activation still rejects after the notification's contained sync. Focused package coverage remains 100%, and the bilingual reconnect note records the ownership rule. --- ...-08-06-mcp-client-auto-reconnect.i18n.yaml | 4 +-- .../2026-08-06-mcp-client-auto-reconnect.md | 4 +-- ...2026-08-06-mcp-client-auto-reconnect.zh.md | 4 +-- packages/mcp/mcp-client/src/connection.ts | 21 ++++++++------- packages/mcp/mcp-client/tests/apply.spec.ts | 26 +++++++++++++++++++ 5 files changed, 43 insertions(+), 16 deletions(-) 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 index e0aac526cc..ffc985cc70 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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: e371fa13dc7bc5330f658f4d6e864969baf93cf0 -2026-08-06-mcp-client-auto-reconnect.zh.md: eb6e67dabd7c6115b9ecb507029ffa7d45338466 +2026-08-06-mcp-client-auto-reconnect.md: 75e17da716f817306bb30322678d26b0e18a5aff +2026-08-06-mcp-client-auto-reconnect.zh.md: 0ba06d17dca895be8449eb771d34dc096307d176 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 index e371fa13dc..75e17da716 100644 --- 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 @@ -14,7 +14,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. **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. 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. +**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. @@ -38,7 +38,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. ## Testing -Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, 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. +Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, 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 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 index eb6e67dabd..0ba06d17dc 100644 --- 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 @@ -14,7 +14,7 @@ Status: implemented **触发条件。** 监督器在每一代上挂载 `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` 通知同时触发重新同步。失败信号按代幂等:一次连接拒绝与其自身 transport 关闭竞态时,仅调度恰好一次重试。失败尝试只有在 `Client.close()` 结算且 transport 报告 `onclose` 后才能进入退避;对 stdio 而言,`onclose` 证明子进程已退出;若关闭信号始终未到,则在 SDK 的有界终止窗口结束后停止重连,而不是允许两个服务器进程重叠运行。dispose 使用同一个有界关闭信号屏障;若关停未完成则予以报告,且绝不重启。 +**代隔离,无交错。** 每次尝试构建一个全新的 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`,作为最长退避间隔从配置推导得出而非作为第五个独立调参项——之后重置预算;因此偶尔崩溃的服务器可无限恢复,而连接短暂成功后立即再次崩溃的循环无法将其预算洗白为重启风暴。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试(`tests/reconnect.spec.ts`,mock SDK):恢复在不产生重复或泄漏的前提下切换代并服务恢复后的调用、初始化失败会等待旧代的关闭信号,若该信号始终未到则停止重连、dispose 同样等待同一关闭信号,并在有界等待到期时报告关停未完成、失败上限注销工具并停止、dispose 取消待执行的退避并使进行中的同步完全停稳、dispose 后的关闭不调度任何操作、禁用模式保持 v1 行为、稳定窗口重置预算而崩溃循环耗尽预算、双重失败信号仅调度一次重试、过时的代和处理器为惰性、`resolveReconnectPolicy` 拒绝每个无效边界值。E2E(`tests/mcp-client.e2e.ts`,无需密钥):fixture 服务器新增了一个 `crash` 工具(先回复再退出);真实进程测试证明 stdio 崩溃端到端恢复,以及在故障期间卸载插件能立即停止重连。快照:刻意不做,原因与原 Agent Note 相同——重连不引入新的展示形态,而在快照组合中 spawn 崩溃服务器会使回放依赖时序。 +单元测试(`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 崩溃服务器会使回放依赖时序。 ## 后果 diff --git a/packages/mcp/mcp-client/src/connection.ts b/packages/mcp/mcp-client/src/connection.ts index 2760324a20..3afd05b538 100644 --- a/packages/mcp/mcp-client/src/connection.ts +++ b/packages/mcp/mcp-client/src/connection.ts @@ -133,7 +133,6 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe const startupOpts: ToolBridgeOptions = config.failOnStartupError ? { ...opts, registrationFailure: 'throw' } : opts - let isFirstSync = true let disposed = false /** Current generation: the connecting or connected client; undefined during backoff waits and after final failure. */ @@ -160,9 +159,7 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe * generation and leak another). */ let syncChain: Promise = Promise.resolve() - function enqueueSync(generation: Client): Promise { - const syncOpts = isFirstSync ? startupOpts : opts - isFirstSync = false + function enqueueSync(generation: Client, syncOpts: ToolBridgeOptions = opts): Promise { const run = syncChain.then(async () => { if (!isCurrent(generation)) return disposers = await syncTools(generation, ctx, syncOpts, disposers) @@ -219,7 +216,7 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe ctx.logger.warn(`${label}: connection lost; reconnecting in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`) reconnectTimer = setTimeout(() => { reconnectTimer = undefined - settling = connectGeneration() + settling = connectGeneration(false) }, delayMs) // An armed reconnect timer must never hold the process open on its own. reconnectTimer.unref() @@ -228,10 +225,14 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe /** * One connection attempt: fresh transport + client (the MCP SDK binds a * Protocol to one transport for life), connect, then queue the initial tool - * sync. Every failure funnels through {@link generationDown}; success arms - * the onclose-driven disconnect path. Never rejects. + * 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(): Promise { + async function connectGeneration(startup: boolean): Promise { const generation = new Client( { name: 'dsh-mcp-client', version: '0.0.1' }, { capabilities: {} }, @@ -272,7 +273,7 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe generationDown(generation) return } - await enqueueSync(generation) + await enqueueSync(generation, startup ? startupOpts : opts) } catch (error) { if (firstAttemptError === undefined) firstAttemptError = error // Disposal clears current ownership before it closes the generation, so @@ -302,7 +303,7 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe } /** The in-flight (or last settled) connection attempt; dispose awaits it for quiescence. */ - let settling = connectGeneration() + 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 diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index b6a9409c63..2d836d710e 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -288,6 +288,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) From bdd0e6a709378b6a6890fc3b60a6f8663496b26e Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:01:31 +0800 Subject: [PATCH 033/113] test(mcp-client): pin give-up cleanup ordering The failure-cap path already appends tool disposal to syncChain, but the existing tests only covered give-up after settled discovery. They could not detect a future change that disposed the old set immediately and then allowed a blocked re-sync to publish a new leaked generation. Hold a list_changed fetch open, drive the reconnect budget to exhaustion, then release a different tool list. The test proves final cleanup runs after that in-flight swap and removes both the previous and late-published tool names while creating no attempt beyond the configured cap. --- .../mcp/mcp-client/tests/reconnect.spec.ts | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/packages/mcp/mcp-client/tests/reconnect.spec.ts b/packages/mcp/mcp-client/tests/reconnect.spec.ts index 6d85e753ea..8eb4bbf373 100644 --- a/packages/mcp/mcp-client/tests/reconnect.spec.ts +++ b/packages/mcp/mcp-client/tests/reconnect.spec.ts @@ -195,6 +195,32 @@ describe('reconnect supervisor', () => { 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 () => { mockConnect.mockRejectedValueOnce(new Error('initialize failed')) // Model the SDK's fire-and-forget close after initialize fails: the From 0e01036a2a50531ae4bee892232dcba89845835c Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:02:13 +0800 Subject: [PATCH 034/113] test(mcp-client): preserve startup error cause Strict startup intentionally wraps connection and synchronization failures with the server-qualified activation diagnostic while retaining the original error in Error.cause. The prior assertion checked only the wrapper text, so the causal chain could regress unnoticed and erase the actionable transport failure. Assert the full wrapper message and object identity of the original connection error in cause. This keeps operator-facing context and the underlying SDK diagnostic independently stable without changing production behavior. --- packages/mcp/mcp-client/tests/apply.spec.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/packages/mcp/mcp-client/tests/apply.spec.ts b/packages/mcp/mcp-client/tests/apply.spec.ts index 2d836d710e..d4f301be54 100644 --- a/packages/mcp/mcp-client/tests/apply.spec.ts +++ b/packages/mcp/mcp-client/tests/apply.spec.ts @@ -254,11 +254,15 @@ describe('apply (plugin lifecycle)', () => { }) 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() From e2556c51bf957bca4ddd0c8ffdd2c1e75fc9a9e9 Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:05:06 +0800 Subject: [PATCH 035/113] fix(mcp-client): distinguish failure from loss The reconnect supervisor used connection lost for every transition into backoff, including an initial startup attempt that never established a connection and later retry attempts that also failed. That wording implied a previously healthy generation and obscured whether any tools had ever been registered. Capture whether the generation had reached the established state before scheduling recovery. Established disconnects retain connection lost/reconnecting; startup and retry failures now report connection failed/retrying. The reconnect-disabled diagnostic uses the same distinction while preserving its concrete manual-recovery guidance. Unit assertions cover established loss, initial failure, retry failure, and both reconnect-disabled branches. Focused package coverage remains 100%, and the bilingual Agent Note records the observable state vocabulary. --- .../2026-08-06-mcp-client-auto-reconnect.i18n.yaml | 4 ++-- .../feature/2026-08-06-mcp-client-auto-reconnect.md | 4 ++-- .../2026-08-06-mcp-client-auto-reconnect.zh.md | 4 ++-- packages/mcp/mcp-client/src/connection.ts | 12 +++++++----- packages/mcp/mcp-client/tests/reconnect.spec.ts | 6 +++++- 5 files changed, 18 insertions(+), 12 deletions(-) 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 index ffc985cc70..2f6713b4ab 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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: 75e17da716f817306bb30322678d26b0e18a5aff -2026-08-06-mcp-client-auto-reconnect.zh.md: 0ba06d17dca895be8449eb771d34dc096307d176 +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 index 75e17da716..99a8aec1ab 100644 --- 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 @@ -20,7 +20,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. **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.** Reconnecting logs at warn with attempt count and delay, recovery at info, final failure and disabled-loss 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. +**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. @@ -38,7 +38,7 @@ The [MCP client](2026-07-07-mcp-client-plugin.md) connected once at plugin load. ## Testing -Unit (`tests/reconnect.spec.ts`, mocked SDK): recovery swaps generations without duplication or leaks and serves post-recovery calls, 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. +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 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 index 0ba06d17dc..8d4dc935e6 100644 --- 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 @@ -20,7 +20,7 @@ Status: implemented **配置与解析。** 两种传输均接受 `reconnect { enabled, initialDelayMs, maxDelayMs, maxAttempts }` 配置,Schemastery 默认值为(启用、500ms、30s、10)。`resolveReconnectPolicy()` 是显式的解析步骤:它重新校验每个边界值和跨字段约束,因为程序化构造可能绕过 Schemastery,配置错误在加载时即令插件实例失败。 -**可观测状态。** 重连中以 warn 级别记录尝试次数和延迟,恢复以 info 级别记录,最终失败和禁用状态下的断连以 error 级别记录。故障期间,上一个正常代保持注册,对其工具的调用返回失败——确定性公开名称意味着恢复后未变化的工具列表会复现相同的定义,保持模型可见 schema 前缀稳定而非反复抖动。设置 `reconnect.enabled: false` 后,断连保持 v1 的手动恢复行为。 +**可观测状态。** 初始尝试或重试尝试失败时记录 `connection failed`,已建立的代结束时记录 `connection lost`;重试的 warn 日志包含尝试次数和延迟,恢复以 info 级别记录,最终失败和禁用重连时的断连以 error 级别记录。故障期间,上一个正常代保持注册,对其工具的调用返回失败——确定性公开名称意味着恢复后未变化的工具列表会复现相同的定义,保持模型可见 schema 前缀稳定而非反复抖动。设置 `reconnect.enabled: false` 后,断连保持 v1 的手动恢复行为。 **资源释放。** dispose 翻转栅栏、取消待执行的定时器、关闭当前 client,然后等待正在进行的尝试和同步队列完成后再注销工具——完全停稳,而非仅发出停止请求。重连定时器使用 unref,因此等待中的退避不会阻止进程正常退出。 @@ -38,7 +38,7 @@ Status: implemented ## 测试 -单元测试(`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 崩溃服务器会使回放依赖时序。 +单元测试(`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 崩溃服务器会使回放依赖时序。 ## 后果 diff --git a/packages/mcp/mcp-client/src/connection.ts b/packages/mcp/mcp-client/src/connection.ts index 3afd05b538..f452e3a81d 100644 --- a/packages/mcp/mcp-client/src/connection.ts +++ b/packages/mcp/mcp-client/src/connection.ts @@ -190,11 +190,12 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe } function scheduleReconnect(): void { + const lostEstablishedConnection = connectedAt !== undefined if (!policy.enabled) { - const detail = connectedAt !== undefined - ? 'registered tools will fail until an HMR reload or Host restart' - : 'no tools were registered; reload the plugin or restart the Host to connect' - ctx.logger.error(`${label}: connection lost and reconnect is disabled — ${detail}`) + 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 @@ -213,7 +214,8 @@ export function startConnection(ctx: Context, config: Config, policy: ResolvedRe return } const delayMs = Math.min(policy.maxDelayMs, policy.initialDelayMs * 2 ** (failedAttempts - 1)) - ctx.logger.warn(`${label}: connection lost; reconnecting in ${delayMs}ms (attempt ${failedAttempts}/${policy.maxAttempts})`) + 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) diff --git a/packages/mcp/mcp-client/tests/reconnect.spec.ts b/packages/mcp/mcp-client/tests/reconnect.spec.ts index 8eb4bbf373..bdbae26a7e 100644 --- a/packages/mcp/mcp-client/tests/reconnect.spec.ts +++ b/packages/mcp/mcp-client/tests/reconnect.spec.ts @@ -191,6 +191,7 @@ describe('reconnect supervisor', () => { // 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) }) @@ -222,6 +223,7 @@ describe('reconnect supervisor', () => { }) 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. @@ -235,6 +237,7 @@ describe('reconnect supervisor', () => { 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 () => { @@ -329,7 +332,7 @@ describe('reconnect supervisor', () => { 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('reconnect is disabled'))).toBe(true) + 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) @@ -337,6 +340,7 @@ describe('reconnect supervisor', () => { 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) }) From a7c056c512484962dda3b0574dc9ff11444e5e9c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:35:06 +0800 Subject: [PATCH 036/113] docs(release): propose three independent npm publish sequences packages/, vendor/, and native/ carry different version baselines and change at different times, so each releases on its own bump sequence, tag prefix, and workflow. Records the vendor change judgement (per-package tags as the last-published pointer), the registry-state publish rule that makes re-runs idempotent, and the workspace:^ prerequisite for publishing at any version other than the current 0.0.1. English counterpart and pairing record follow once the Chinese text is reviewed. --- .../2026-08-10-npm-release-sequences.zh.md | 186 ++++++++++++++++++ 1 file changed, 186 insertions(+) create mode 100644 .agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md diff --git a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md new file mode 100644 index 0000000000..836b5e863b --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md @@ -0,0 +1,186 @@ +# Agent Note: 三条独立序列的私有 NPM 发布 + +Status: proposed + +[English](2026-08-10-npm-release-sequences.md) | 中文 + +## 问题 + +这个仓库有三组互不相干的可发布包,但没有任何发布通道把它们送上 registry。 + +`packages/*/*` 与 `apps/*` 组成 `@deepseek-ai/dsh` 的运行时闭包;`vendor/*` 是九个 rescope 过的 Cordis 框架包,各自带着上游的版本号;`native/landlock-run/packages/*` 是 Linux 平台包,已有自己的 `landlock-run-release.yml`。三组的版本基线、变更节奏和构建要求都不同: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 规定不带预发布段的范围排除预发布版本)。这 933 条至今没出事,只因为版本一直停在 `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`) | 全族一个 `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 下的**私有包**(`npm publish --access restricted`)。native 三个包现在写的是 `access: public`,要改成 `restricted`。 + +### 版本由本地命令写进仓库,CI 只核对与上传 + +每条序列有一条 `bump and commit` 命令:算出目标版本 → 写进相关 manifest → `pnpm install --lockfile-only` → 立刻自检 → `git add` manifest 与 lockfile → commit。发布版本因此在仓库里查得到,不存在「发出去的是哪个版本说不清楚」。tag 由人工在合入 master 后打,CI 不写仓库、不需要写权限。 + +dsh 序列全族共用一个版本,接受 `major | minor | patch | x.y.z` 三种入参与显式版本号。先用 `0.0.1-rc.1` 这类预发布号把 pack、仓外安装探针、真实私有发布跑通一遍,验证通过后再发 `0.0.1`、`0.0.2` 这样的数字版本。dist-tag 沿用本仓 `landlock-run-release.yml` 已有的判定:版本带预发布段就 `--tag next`,否则进 `latest`。 + +### vendor:谁改了谁发版,tag 就是账本 + +vendor 九包加了 scope 之后与上游脱钩,但保留各自的版本线。发布版本 = 去掉预发布段后 patch+1,首发目标: + +| 包 | 上游版本 | 首发版本 | +|---|---|---| +| `@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 | + +只发改动过的包。变更判据不引入新的状态文件:**每包一个 tag,tag 就是「上次发布到哪个 commit」的记录**。bump 对每个包取最新的 `vendor-<包名>-v*` tag,`git diff <该 tag>..HEAD -- vendor/<目录>` 有差异就 patch+1,没差异就跳过;查不到 tag 就按上表首发。差异只看会进 tarball 的路径(复用 `scripts/publication-payload.ts` 的 `files` 规则),改动 vendor 内的注释不触发发版。 + +tag 只是 commit 指针,不是「已发布」的证明——打了 tag 而 publish 失败的情况必须能识别。所以 bump 还要向 registry 核对「tag 所指版本确实存在」,不一致时明确失败交人处理,不让脚本猜。私有包查询需要鉴权,本机未登录时跳过这条核对,CI 里强制执行。 + +vendor 九包**内部**的依赖范围不用改:`^1.8.1` 容纳 `1.8.2`、`^1.0.0-rc.5` 容纳 `1.0.1`,patch+1 永远落在范围内。 + +### publish 只在 GitHub 执行,用 registry 状态决定发什么 + +发布只从 GitHub Actions 执行,没有本机发布路径。这让「向 registry 核对」成为 CI 里的强制步骤,不需要为本机未鉴权的情况留旁路。 + +publish 不读 tag、不读任何清单,对发布集里每个包比较 manifest 版本与 registry 上的已发布状态,按三态处置: + +| 状态 | 处置 | +|---|---| +| registry 上没有该版本 | 发布 | +| 已有该版本,且 tarball 的 sha512 与 registry 记录的 `dist.integrity` 相同 | 跳过,属于同一批产物的重跑 | +| 已有该版本,但 integrity 不同 | 失败退出,报「内容已变但版本未 bump」 | + +第三态是这条规则的目的:它拦住「改了代码却没 bump 版本」。前两态给出的是幂等——同一个 artifact 重跑 publish 不会重复发布,也不需要人工挑拣包。 + +这条规则同时解决了一次发布事件产生多个 vendor tag、而 workflow 只能从一个 ref 触发的矛盾:workflow 不需要从 tag 推断本次该发哪些包。dsh 序列同构处理,它只有一个版本,差集要么全发要么全跳。 + +第三态依赖同输入构建可复现(同一 commit 两次 pack 得到相同字节)。这一点必须实测确认,不能假定:`pnpm run build` 的产物若嵌入绝对路径或时间,integrity 就会在内容未变时漂移,第三态会误报。落地前先在 CI 上对同一 commit 连跑两次 pack 比对 integrity;若不可复现,则把比较下沉到 tarball 内的逐文件内容哈希,并明确排除导致漂移的字段。 + + +### 一次性把 workspace 内部引用改成 `workspace:^` + +仓库里所有指向 workspace 成员的引用统一成 `workspace:^`,由 `pnpm pack` 在发布时替换成匹配目标版本的范围。 + +| 面 | 数量 | 效果 | +|---|---|---| +| dsh 兄弟包 `peerDependencies` | 933 | 发 `0.0.2` 或 `0.0.1-rc.1` 都自动得到匹配的范围 | +| 指向 vendor 的 dep / peer / devDep | 105 + 221 + 218 | vendor patch+1 后不需要改写 dsh 侧引用,范围也不会随 vendor 递增而过期 | + +`scripts/check-workspace-constraints.ts` 现在断言 vendor peer 与 dev 的范围相等,改后两边都是 `workspace:^`,断言仍成立但语义要随之更新。 + +这条是「发布期不做任何依赖改写」的前提:发布期只做一件事——pack 出字节。 + +### 发布族对象 + +领域里的实体是**发布族**:一组共享版本基线与 tag 前缀、可整体发布的包。新增一族等于新增一份族描述加一条 workflow lane,不改核心。 + +| 对象 | 职责 | +|---|---| +| `ReleaseFamily` | 一族的身份:成员发现规则、版本策略、tag 命名、publish 目标。新增发布族在此落地 | +| `ReleaseMember` | 一个可发布包:目录、manifest、族归属、发布顺序位次 | +| `VersionPolicy` | 版本从哪来。`SharedSemver`(dsh:全族一个版本)与 `PerPackageChanged`(vendor:按 tag 判变更、去预发布段后 patch+1) | +| `ReleaseSet` | 一族成员的拓扑序,按 `dependencies` 排、同层按包名排,保证确定性 | +| `PackedBundle` | tarball 集合 + `publish-order.txt` + 元数据清单,是 pack 与 publish 之间唯一的交接物 | +| `PublishTarget` | registry、access、dist-tag、凭据来源。dist-tag 由版本形态派生 | +| `VersionInvariant` | 族内版本自洽;publish 必须从对应 tag 跑;tag 版本等于包版本;待发版本不得已存在于 registry | +| `PayloadInvariant` | tarball 内容校验,复用 `scripts/publication-payload.ts` | +| `InstalledProbe` | 仓外临时 consumer 从 tarball 安装后,用普通 Node 驱动已安装入口:`dsh --version`、`dsh --dump-default-config`、起一次 TUI 到就绪后退出。实现从 `scripts/publish-npm-baseline.ts` 搬运复用 | + +### workflow 形状:一次性 pack 全部,再统一 publish + +照参照流程(node-addon-require-builtin 的 `release.yml` 与 `scripts/pack-release.mjs`)的形状:`pack` job 一趟遍历整个发布集,逐包 `pnpm --dir <目录> pack --pack-destination <同一个目录>`,写出 `publish-order.txt`,整个目录作为**一份** artifact 上传;`publish` job 下载那一份 artifact,按 `publish-order.txt` 逐个 `npm publish`。发布集是一个整体,不存在「一半的包发出去了、另一半还在构建」。 + +`pack` job 无凭据:install → verify → build → pack → 打包后安装验证 → upload-artifact。`publish` job 挂 `environment: npm-publish` 人工审批,只 `setup-node` 加 `download-artifact`,**不 checkout、不 build**,上传的就是 pack 出来的同一份字节。checkout 用 `fetch-depth: 0`,vendor 的变更判据需要历史与 tag。 + +`environment` 是整条流程唯一的刹车:pack 无凭据、可随意排练;只有 publish 会停在审批上。GitHub 侧需要 `NPM_TOKEN` secret(对该 scope 有发布权限的 automation token)与 `npm-publish` environment(required reviewers,允许的 tag 限制为 `dsh-v*`、`vendor-*`、`landlock-run-v*`)。 + +### PR 阶段跑到 pack 为止 + +参照流程只有 `workflow_dispatch`,PR 上什么都验证不了。本仓在 `pull_request` 上跑完整的 pack:install → verify → build → 逐包 pack → 上传 tarball artifact。它证明的是「这个发布集现在能完整打出来」,无凭据、不碰任何 registry,fork 发来的 PR 也能跑。产物本身的正确性由既有测试覆盖,不在 PR 这一层重复。 + +发布路径的测试走 master:`push: master` 跑同一套 pack 排练作为合入后回归,`workflow_dispatch` 带 `publish: true` 从 tag 走真实发布。 + + + +### 仓库改造项 + +| 项 | 内容 | +|---|---| +| 发布集 manifest | 去掉 `private: true`,补 `publishConfig`(`access: restricted`、`registry`) | +| 发布集边界 | 一份显式的族与成员清单,脚本据此发现成员并校验闭包内的包都在清单里,不猜 | +| 依赖协议 | workspace 内部引用统一 `workspace:^`,并更新 `check-workspace-constraints.ts` | +| 根 `AGENTS.md` | 现在写着 vendored 包是 rescope 过且 `private: true`,vendor 要发布,这条约定要改 | +| `vendor/README.md` | manifest 表补记上游版本,与我们发布的版本区分开 | +| native 三包 | `publishConfig.access` 从 `public` 改 `restricted` | + +### 与既有提案的关系 + +本 Note 取代 [以产物为先的 NPM 基线发布](2026-08-04-artifact-first-npm-baseline-publication.zh.md) 中的版本方案与发布集边界两部分:那篇的 `-<时间戳>-<短 SHA>` 预发布版本与 `dev-` dist-tag 不再采用,vendor 也不再排除在发布集之外。两篇一致的部分保留:pack 与 publish 分离、publish 只消费已验证的 tarball、payload 与安装后探针作为发布门。 + +## 考虑过的替代方案 + +**`-<时间戳>-<短 SHA>` 版本号。** 曾计划用它做持续 dev 发布。它与「版本必须落进代码库」冲突:版本内嵌 commit SHA,而把版本写回 manifest 会产生新的 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 携带的是它自己那个包的真实版本。 + +**vendor 九包统一到 `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 驱动。它绕过版本范围解析,理论上验证不了「200 多个互相依赖的包能不能从 registry 装起来」。曾提议在 CI 里起本地 registry 补这一层,被否:产物验证已由既有测试覆盖,发布路径的验证放在 master workflow 的排练里,PR 只需证明发布集能完整打出来。 + +**以 `scripts/publish-npm-baseline.ts` 为基础扩展。** 它是本机发布脚本,把 pack 与 publish 放在同一进程,与「无凭据 pack、受保护 publish」的分离相反。它验证过的零件(payload 校验、已安装产物探针)搬运复用,避免 `pnpm run duplication` 判重复。 + +**发布全部 217 个 workspace 包。** 发布集定为入口闭包。`support/`、`examples/` 这类包进 registry 只扩大攻击面与维护面,没有消费方。 + +**一个 workflow 用 `family` 输入选择序列。** 两套版本模型塞进一个文件会让 concurrency group、tag 前缀、排练触发条件全部分叉成条件表达式。一族一个文件更短也更好读。 + +**在发布期改写依赖范围。** 与一次性改成 `workspace:^` 相比,改写逻辑只在 CI 执行过,本机 `pnpm install` 看不见它是否正确,且每次发布都要重跑一遍。 + +**CI 里执行 bump 并把版本推回仓库。** 需要给 workflow 仓库写权限,且发布分支上的版本提交会与人的提交竞争。参照流程把 bump 与 commit 留在本地命令,CI 只核对与上传。 + +## 验收标准 + +1. 三条序列各自可独立发布:发 dsh 不改动 vendor 与 native 的任何 manifest,反之亦然。 +2. `pnpm release:dsh <版本>` 一条命令完成 bump 与 commit,产出的 commit 含全族 manifest 与 lockfile,且立刻自检通过。 +3. `pnpm release:vendor` 只对「自其 `vendor-<包名>-v*` tag 以来 tarball 内容有变化」的包 patch+1,无变化的包 manifest 不被改动。 +4. `pull_request` 上跑完整 pack 并产出 tarball artifact,无凭据、不访问真实 registry,fork 的 PR 也能跑。 +5. `push: master` 跑同一套 pack 排练;真实发布只能由 `workflow_dispatch` 带 `publish: true` 从对应 tag 触发。 +6. publish 重跑同一 artifact 不重复发布已存在的版本;当某个版本已存在而 tarball integrity 不同时,publish 失败并指明是哪个包。 +7. 仓外临时 consumer 安装 `@deepseek-ai/dsh@0.0.1-rc.1` 后,用普通 Node 能跑通 `--version`、`--dump-default-config` 与一次 TUI 启动。 +8. 所有 workspace 内部引用为 `workspace:^`,且 pack 出的 tarball 里没有任何 `workspace:` 残留、没有指向不存在版本的范围。 +9. 发布集内没有 `private: true`,每个成员都有 `publishConfig.access: restricted`。 + +## 风险 + +**tag 与 registry 漂移。** 打了 tag 但 publish 失败,会让下一次 bump 误判该包已发布。缓解手段是 bump 向 registry 核对 tag 所指版本,不一致就失败退出;但本机未登录私有 registry 时这条核对被跳过,此时误判只能由 CI 的同一条核对拦下。 + +**变更判据依赖 tag 可见。** shallow clone 或未拉取 tag 会让 vendor 的判据失效并退化成「全部首发」。`fetch-depth: 0` 是这条判据的前提,不是优化。 + +**`workspace:^` 改动面大。** 一次触及 1477 处依赖声明。它不改变本机解析行为(pnpm 本来就从 workspace 解析),但会改变发布出去的范围写法,且要同步更新 workspace 约束门。 + +**私有包的可见性代价。** `--access restricted` 之后,任何消费方(含 CI、沙箱 e2e、外部使用者)都必须持有 scope 凭据才能安装。native 三包从 public 转 restricted 会切断现有匿名安装路径。 + +**首发一次性放大。** vendor 首发九包、dsh 首发全闭包,任何 payload 缺陷都会在同一次发布里暴露。用 `0.0.1-rc.1` 先跑一遍完整链路是唯一的缓解手段,正式版本号留给验证通过之后。 From 8cd38945f192d484f4011d33971aa35616214469 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:35:23 +0800 Subject: [PATCH 037/113] feat(release): add release family metadata, pack, verify, and publish A release family owns its member discovery, version baseline, tag naming, and packed-payload rule; the dsh family shares one version across packages/ and apps/, while every vendor/ package keeps its own version line. Publish order is topological over runtime dependencies so no package reaches the registry before one it depends on. pack packs the whole family into one directory and records the upload order; publish decides per package against the registry, skipping a version whose published tarball has the same integrity and failing when it differs, which is what makes re-running publish over one artifact safe. The vendored packages keep upstream's payload: their manifests export ./src/*, so the harness rule that rejects sources and declaration maps would publish an export map pointing at absent files. --- package.json | 3 + scripts/release/families.ts | 282 ++++++++++++++++++++++++++++++++++++ scripts/release/pack.ts | 86 +++++++++++ scripts/release/publish.ts | 130 +++++++++++++++++ scripts/release/verify.ts | 69 +++++++++ 5 files changed, 570 insertions(+) create mode 100644 scripts/release/families.ts create mode 100644 scripts/release/pack.ts create mode 100644 scripts/release/publish.ts create mode 100644 scripts/release/verify.ts diff --git a/package.json b/package.json index 77816c319d..32138d03e8 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,9 @@ "doc-sync": "tsx scripts/run-gates.ts doc-sync", "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", + "release:verify": "tsx scripts/release/verify.ts", + "release:pack": "tsx scripts/release/pack.ts", + "release:publish": "tsx scripts/release/publish.ts", "dsh": "node --import tsx/esm apps/cli/src/bin.ts", "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts --profile headless", "demo:code-mode": "node scripts/demo-code-mode.mjs", diff --git a/scripts/release/families.ts b/scripts/release/families.ts new file mode 100644 index 0000000000..e989e54525 --- /dev/null +++ b/scripts/release/families.ts @@ -0,0 +1,282 @@ +/** + * 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/proposed/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 +} + +/** 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 a member publishes from. + * @param member - the member being published. + * @returns The full tag name, without `refs/tags/`. + */ + abstract tagFor(member: ReleaseMember): string + + /** + * 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 +} + +/** `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 tag. + * @param member - any family member; all carry the same version. + * @returns `dsh-v`. + */ + tagFor(member: ReleaseMember): string { + return `${this.tagPrefix}${member.version}` + } + + /** + * 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), + }) + } +} + +/** `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}`) + } + } + } + + /** + * The member's own tag, because one vendor release can carry several versions. + * @param member - the member being published. + * @returns `vendor--v`. + */ + tagFor(member: ReleaseMember): string { + return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v${member.version}` + } + + /** + * 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`) + } +} + +/** Every release family this module owns, in workflow order. */ +export 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..1b128817b9 --- /dev/null +++ b/scripts/release/pack.ts @@ -0,0 +1,86 @@ +/** + * 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/proposed/process/2026-08-10-npm-release-sequences.md)). + */ + +import { spawnSync } from 'node:child_process' +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' + +/** Where pack output lands when `--out` is omitted. */ +const DEFAULT_OUTPUT = 'dist/npm' + +/** Name of the file the publish step reads to learn the upload order. */ +export const PUBLISH_ORDER_FILE = 'publish-order.txt' + +/** + * Run a command, inheriting stdio, and fail the process on a non-zero exit. + * @param command - executable name. + * @param args - command arguments. + */ +function run(command: string, args: readonly string[]): void { + const result = spawnSync(command, [...args], { stdio: 'inherit' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`) +} + +/** + * List a tarball's members. + * @param tarball - absolute tarball path. + * @returns Every path inside the archive. + */ +function tarballFiles(tarball: string): string[] { + const result = spawnSync('tar', ['-tzf', tarball], { encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`tar -tzf ${tarball} exited with ${String(result.status)}:\n${result.stderr}`) + return result.stdout.split('\n').filter(line => line !== '') +} + +/** + * 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}`) +} + +main() diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts new file mode 100644 index 0000000000..68dae1701a --- /dev/null +++ b/scripts/release/publish.ts @@ -0,0 +1,130 @@ +/** + * 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/proposed/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 { spawnSync } from 'node:child_process' +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 { PUBLISH_ORDER_FILE } from './pack.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 } + +/** + * Read a packed tarball's own manifest. + * @param tarball - absolute tarball path. + * @returns The packed `package.json` name and version. + */ +function packedIdentity(tarball: string): { name: string; version: string } { + const result = spawnSync('tar', ['-xOzf', tarball, 'package/package.json'], { encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`cannot read ${tarball}:\n${result.stderr}`) + const manifest: unknown = JSON.parse(result.stdout) + 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 } +} + +/** + * 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 = spawnSync('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json'], { encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + 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 one tarball. + * @param tarball - absolute tarball path. + * @param version - the version being published; a prerelease never takes `latest`. + */ +function publish(tarball: string, version: string): void { + const args = ['publish', tarball, '--access', ACCESS] + if (version.includes('-')) args.push('--tag', 'next') + const result = spawnSync('npm', args, { stdio: 'inherit' }) + if (result.error !== undefined) throw result.error + if (result.status !== 0) throw new Error(`npm publish ${tarball} exited with ${String(result.status)}`) +} + +/** 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) + const order = readFileSync(join(directory, PUBLISH_ORDER_FILE), 'utf8').split('\n').filter(line => line !== '') + + let published = 0 + let skipped = 0 + for (const filename of order) { + 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 + } + publish(tarball, version) + published += 1 + } + + console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`) +} + +main() diff --git a/scripts/release/verify.ts b/scripts/release/verify.ts new file mode 100644 index 0000000000..98988de18d --- /dev/null +++ b/scripts/release/verify.ts @@ -0,0 +1,69 @@ +/** + * 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/proposed/process/2026-08-10-npm-release-sequences.md)). + */ + +import { parseArgs } from 'node:util' +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' : ''}`) +} + +main() From 4e91230dd698bc2f5890661ebf10c7516c9c148d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:35:36 +0800 Subject: [PATCH 038/113] ci(release): pack on pull requests and publish from family tags Each sequence gets its own workflow so the two version models never meet in one file. 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 guarded by the npm-publish environment, runs only from that family's tag, and never builds - it uploads the bytes pack produced. --- .github/workflows/release-vendor.yml | 125 ++++++++++++++++++++++++++ .github/workflows/release.yml | 127 +++++++++++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 .github/workflows/release-vendor.yml create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/release-vendor.yml b/.github/workflows/release-vendor.yml new file mode 100644 index 0000000000..b83547b560 --- /dev/null +++ b/.github/workflows/release-vendor.yml @@ -0,0 +1,125 @@ +# 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: + group: ${{ github.workflow }} + 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 + + - 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 + permissions: + contents: read + id-token: write + 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..8b4005f42f --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,127 @@ +# 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: + # dist-tags are shared registry state; never race two release runs. + group: ${{ github.workflow }} + 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 + + - 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 + permissions: + contents: read + id-token: write + 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 From 97eb14a007cc554cead5e731db3b21605b26e988 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:02:53 +0800 Subject: [PATCH 039/113] build(release): make the release set publishable under the private scope Every package under packages/, apps/, and vendor/ drops "private": true and declares publishConfig.access "restricted": the repository now states which packages it publishes instead of deciding it at publish time. Each one also declares its repository and directory, which is how a consumer of a private package reaches its source. The Landlock packages move to restricted with them. They have never been published, so nothing anonymous depends on them today, and the whole @deepseek-ai scope stays private. The workspace constraint that required every package to be private now applies to non-members only, and asserts the publishable trio on each release member. --- apps/cli/package.json | 9 ++++++- apps/web/package.json | 9 ++++++- .../landlock-run/packages/entry/package.json | 2 +- .../packages/linux-arm64/package.json | 2 +- .../packages/linux-x64/package.json | 2 +- packages/acp/acp/package.json | 9 ++++++- packages/api/gateway/package.json | 9 ++++++- packages/api/remotes/package.json | 9 ++++++- .../attachment/attachment-local/package.json | 9 ++++++- packages/attachment/attachment/package.json | 9 ++++++- packages/bash/bash-env/package.json | 9 ++++++- packages/bash/bash-local/package.json | 9 ++++++- packages/bash/bash-sandbox/package.json | 9 ++++++- packages/bash/bash/package.json | 9 ++++++- packages/bash/pwsh-local/package.json | 9 ++++++- packages/bash/pwsh-sandbox/package.json | 9 ++++++- packages/bash/tool-bash/package.json | 9 ++++++- packages/bash/tool-pwsh/package.json | 9 ++++++- packages/boot/app-boot/package.json | 9 ++++++- packages/bundle/base/package.json | 9 ++++++- packages/bundle/headless/package.json | 9 ++++++- packages/bundle/web-app/package.json | 9 ++++++- packages/client/connection/package.json | 9 ++++++- packages/client/hmr/package.json | 9 ++++++- packages/client/locale/package.json | 9 ++++++- packages/client/modules/package.json | 9 ++++++- packages/client/runtime/package.json | 9 ++++++- packages/client/schema-form/package.json | 9 ++++++- packages/client/test-runtime/package.json | 9 ++++++- packages/client/ui-agent-preset/package.json | 9 ++++++- packages/client/ui-command/package.json | 9 ++++++- packages/client/ui-conversation/package.json | 9 ++++++- packages/client/ui-deliverables/package.json | 9 ++++++- packages/client/ui-goal/package.json | 9 ++++++- packages/client/ui-layout/package.json | 9 ++++++- packages/client/ui-model/package.json | 9 ++++++- packages/client/ui-models/package.json | 9 ++++++- packages/client/ui-permission/package.json | 9 ++++++- packages/client/ui-plan/package.json | 9 ++++++- packages/client/ui-primitives/package.json | 9 ++++++- packages/client/ui-question/package.json | 9 ++++++- .../client/ui-settings-general/package.json | 9 ++++++- packages/client/ui-settings/package.json | 9 ++++++- packages/client/ui-sidebar/package.json | 9 ++++++- packages/client/ui-skill/package.json | 9 ++++++- packages/client/ui-slash/package.json | 9 ++++++- packages/client/ui-slots/package.json | 9 ++++++- packages/client/ui-subagent/package.json | 9 ++++++- packages/client/ui-theme/package.json | 9 ++++++- packages/client/ui-tool/package.json | 9 ++++++- packages/client/ui-trajectory/package.json | 9 ++++++- packages/client/ui-workspace/package.json | 9 ++++++- packages/client/web-react/package.json | 9 ++++++- packages/client/web/package.json | 9 ++++++- .../code-runtime-worker/package.json | 9 ++++++- .../code-runtime/code-runtime/package.json | 9 ++++++- packages/compact/command-compact/package.json | 9 ++++++- packages/compact/compact-basic/package.json | 9 ++++++- .../compact-tool-result-prune/package.json | 9 ++++++- packages/compact/compact/package.json | 9 ++++++- .../context/session-reference/package.json | 9 ++++++- packages/context/time-context/package.json | 9 ++++++- packages/context/tmux-context/package.json | 9 ++++++- .../context/workspace-context/package.json | 9 ++++++- .../core/agent-default-model/package.json | 9 ++++++- packages/core/agent-loop/package.json | 9 ++++++- packages/core/agent-tool-mode/package.json | 9 ++++++- packages/core/agent/package.json | 9 ++++++- packages/core/scope/package.json | 9 ++++++- packages/core/session/package.json | 9 ++++++- packages/core/system-prompt/package.json | 9 ++++++- packages/core/tools/package.json | 9 ++++++- .../credentials-local/package.json | 9 ++++++- packages/credentials/credentials/package.json | 9 ++++++- packages/e2b/e2b/package.json | 9 ++++++- packages/e2b/fs-e2b/package.json | 9 ++++++- packages/e2b/subprocess-e2b/package.json | 9 ++++++- packages/examples/acp-demo/package.json | 9 ++++++- .../examples/agent-spine-demo/package.json | 9 ++++++- packages/examples/jsonrpc-demo/package.json | 9 ++++++- .../feedback/command-feedback/package.json | 9 ++++++- packages/fs/fs-local/package.json | 9 ++++++- packages/fs/fs-policy/package.json | 9 ++++++- packages/fs/fs-sandbox/package.json | 9 ++++++- packages/fs/fs/package.json | 9 ++++++- packages/fs/tool-fs-search/package.json | 9 ++++++- packages/fs/tool-fs/package.json | 9 ++++++- .../fs/tool-str-replace-editor/package.json | 9 ++++++- packages/goal/command-goal/package.json | 9 ++++++- packages/goal/goal-session/package.json | 9 ++++++- packages/goal/goal/package.json | 9 ++++++- packages/goal/tool-goal/package.json | 9 ++++++- packages/guard/repeat-tool-guard/package.json | 9 ++++++- packages/guard/timeout-policy/package.json | 9 ++++++- packages/hooks/hook-protocol/package.json | 9 ++++++- packages/hooks/hooks-claude/package.json | 9 ++++++- packages/hooks/hooks-codex/package.json | 9 ++++++- packages/host/apiproxy/package.json | 9 ++++++- .../host/directory-picker-auto/package.json | 9 ++++++- .../host/directory-picker-browse/package.json | 9 ++++++- .../host/directory-picker-native/package.json | 9 ++++++- packages/host/directory-picker/package.json | 9 ++++++- packages/host/frontend-static/package.json | 9 ++++++- packages/host/webserver/package.json | 9 ++++++- packages/interaction/commands/package.json | 9 ++++++- packages/interaction/permission/package.json | 9 ++++++- .../interaction/tool-ask-user/package.json | 9 ++++++- .../interaction/user-approval/package.json | 9 ++++++- .../interaction/user-interaction/package.json | 9 ++++++- packages/llm/llm-deepseek/package.json | 9 ++++++- packages/llm/llm-pi-ai/package.json | 9 ++++++- packages/llm/llm-retry/package.json | 9 ++++++- packages/llm/llm/package.json | 9 ++++++- packages/llm/token-meter/package.json | 9 ++++++- packages/lsp/lsp-local/package.json | 9 ++++++- packages/lsp/lsp/package.json | 9 ++++++- packages/lsp/tool-lsp/package.json | 9 ++++++- packages/mcp/mcp-client/package.json | 9 ++++++- packages/plan/plan-mode/package.json | 9 ++++++- packages/preset/agent-presets/package.json | 9 ++++++- packages/preset/persona/package.json | 9 ++++++- packages/pty/pty-local/package.json | 9 ++++++- packages/pty/pty/package.json | 9 ++++++- .../pty/tool-bash-persistent/package.json | 9 ++++++- packages/pty/tool-pty/package.json | 9 ++++++- packages/sandbox/sandbox-local/package.json | 9 ++++++- packages/sandbox/sandbox-policy/package.json | 9 ++++++- .../sandbox/sandbox-windows-acl/package.json | 9 ++++++- packages/sandbox/sandbox/package.json | 9 ++++++- packages/scaffold/client/package.json | 9 ++++++- packages/scaffold/create-sdk/package.json | 9 ++++++- packages/scaffold/helper/package.json | 9 ++++++- packages/scaffold/protocol/package.json | 9 ++++++- packages/scaffold/scripts/package.json | 9 ++++++- packages/scaffold/server/package.json | 9 ++++++- packages/scaffold/telemetry/package.json | 9 ++++++- .../tool-cordis/package.json | 9 ++++++- .../session-query-sqlite/package.json | 9 ++++++- .../session-query/session-query/package.json | 9 ++++++- .../tool-session-query/package.json | 9 ++++++- .../session-checkpoint-policy/package.json | 9 ++++++- .../session-persistence-jsonl/package.json | 9 ++++++- .../session-persistence-sqlite/package.json | 9 ++++++- .../session/session-persistence/package.json | 9 ++++++- .../session-projection-cache/package.json | 9 ++++++- .../session/session-projection/package.json | 9 ++++++- .../session-telemetry-otel/package.json | 9 ++++++- .../session/session-telemetry/package.json | 9 ++++++- .../package.json | 9 ++++++- .../package.json | 9 ++++++- .../session/session-title-llm/package.json | 9 ++++++- packages/session/session-title/package.json | 9 ++++++- packages/session/user-id/package.json | 9 ++++++- packages/settings/settings-local/package.json | 9 ++++++- packages/settings/settings/package.json | 9 ++++++- packages/skill/skill-badge/package.json | 9 ++++++- packages/skill/skill-local/package.json | 9 ++++++- packages/skill/skill/package.json | 9 ++++++- packages/skill/tool-skill/package.json | 9 ++++++- packages/spill/spill-local/package.json | 9 ++++++- packages/spill/spill-policy/package.json | 9 ++++++- packages/spill/spill/package.json | 9 ++++++- packages/storage/storage-domain/package.json | 9 ++++++- packages/storage/storage-json/package.json | 9 ++++++- packages/storage/storage-sqlite/package.json | 9 ++++++- packages/storage/storage/package.json | 9 ++++++- packages/subagent/subagent-acp/package.json | 9 ++++++- .../subagent-claude-code/package.json | 9 ++++++- packages/subagent/subagent-codex/package.json | 9 ++++++- .../subagent/subagent-dsh-sdk/package.json | 9 ++++++- packages/subagent/subagent-fork/package.json | 9 ++++++- .../subagent/subagent-inprocess/package.json | 9 ++++++- packages/subagent/subagent-spawn/package.json | 9 ++++++- packages/subagent/subagent/package.json | 9 ++++++- .../tool-subagent-control/package.json | 9 ++++++- .../tool-subagent-report/package.json | 9 ++++++- packages/subagent/tool-subagent/package.json | 9 ++++++- .../subprocess/subprocess-local/package.json | 9 ++++++- packages/subprocess/subprocess/package.json | 9 ++++++- packages/support/acp-snapshot/package.json | 9 ++++++- .../support/agent-loop-testkit/package.json | 9 ++++++- packages/support/invariants/package.json | 9 ++++++- packages/support/llm-mock-server/package.json | 9 ++++++- packages/support/llm-replay/package.json | 9 ++++++- packages/support/loader-smoke/package.json | 9 ++++++- packages/tasks/tasks-local/package.json | 9 ++++++- packages/tasks/tasks/package.json | 9 ++++++- packages/tasks/tool-tasks/package.json | 9 ++++++- packages/todo/tool-todo/package.json | 9 ++++++- packages/typert/generator/package.json | 9 ++++++- packages/typert/loader/package.json | 9 ++++++- packages/typert/registry/package.json | 9 ++++++- packages/typert/type-meta/package.json | 9 ++++++- packages/util/atomic-write/package.json | 9 ++++++- packages/util/brand/package.json | 9 ++++++- packages/util/environment/package.json | 9 ++++++- packages/util/native-command/package.json | 9 ++++++- packages/util/paths/package.json | 9 ++++++- packages/util/retention/package.json | 9 ++++++- packages/util/timeout/package.json | 9 ++++++- packages/web/tool-web/package.json | 9 ++++++- packages/web/web-fetch-local/package.json | 9 ++++++- packages/web/web-search-deepseek/package.json | 9 ++++++- packages/web/web-search-exa/package.json | 9 ++++++- .../web/web-search-perplexity/package.json | 9 ++++++- packages/web/web/package.json | 9 ++++++- packages/workflow/tool-ralph/package.json | 9 ++++++- packages/workflow/tool-workflow/package.json | 9 ++++++- .../workflow-workerthread/package.json | 9 ++++++- packages/workflow/workflow/package.json | 9 ++++++- packages/workspace/workspace/package.json | 9 ++++++- scripts/check-workspace-constraints.ts | 27 +++++++++++++++++-- vendor/cordis/package.json | 9 ++++++- vendor/cosmokit/package.json | 9 ++++++- vendor/group/package.json | 9 ++++++- vendor/hmr/package.json | 9 ++++++- vendor/include/package.json | 9 ++++++- vendor/loader/package.json | 9 ++++++- vendor/logger-console/package.json | 9 ++++++- vendor/schemastery/package.json | 9 ++++++- vendor/timer/package.json | 9 ++++++- 221 files changed, 1764 insertions(+), 222 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 8b50f180f2..3155032205 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", "version": "0.0.1", - "private": true, + "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" diff --git a/apps/web/package.json b/apps/web/package.json index 1bf97cb17c..9e71181b4b 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -2,7 +2,14 @@ "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, + "publishConfig": { + "access": "restricted" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/deepseek-ai/deepseek-harness.git", + "directory": "apps/web" + }, "type": "module", "exports": { "./dist/*": "./dist/*", 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/packages/acp/acp/package.json b/packages/acp/acp/package.json index 0d83ca8c24..c5564b0b2f 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 9720c8530a..10a315302a 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-api-gateway", "description": "TypeRT Remote Host dispatcher and Client API endpoint", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 2a1d92b82a..8a31f138e3 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly and Host Agent/Session lookup policy", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index b11180f133..4219c40d30 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 4ec0fee955..ad212f3933 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/bash/bash-env/package.json b/packages/bash/bash-env/package.json index b2de167a64..8983029c0d 100644 --- a/packages/bash/bash-env/package.json +++ b/packages/bash/bash-env/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-bash-env", "description": "Tool-independent managed DSH_* shell environment registry", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index 2a19e4da5d..f147b01f72 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index b37f9dc48e..b1d1b4a13a 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index d184a18d7b..320f077f28 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-bash", "description": "Abstract bash executor seam (ctx.bash) for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/bash/pwsh-local/package.json b/packages/bash/pwsh-local/package.json index ecfa7f11da..a3b5d6aa7d 100644 --- a/packages/bash/pwsh-local/package.json +++ b/packages/bash/pwsh-local/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/bash/pwsh-sandbox/package.json b/packages/bash/pwsh-sandbox/package.json index fbc5227912..727496274b 100644 --- a/packages/bash/pwsh-sandbox/package.json +++ b/packages/bash/pwsh-sandbox/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index bd467f103e..6440e990b0 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json index 7f18585332..b06df9a992 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 0295742268..f47611040d 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 038e3ea0c6..c2547f9aae 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index e439fe75f7..4bdd04efd9 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index b6740c7b77..c9ec594f28 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 8f7b485c09..f03afb8eda 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 79e2789d33..82d8cb18e1 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index dc90483e1b..f845feacc9 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index a9d9d1de2d..e980619017 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 6250a97ff5..168e916223 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-client-runtime", "description": "Client core services: SlotsService, SessionsService (scope tree + object layer)", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/client/schema-form/package.json b/packages/client/schema-form/package.json index ee6529025c..30e0c24353 100644 --- a/packages/client/schema-form/package.json +++ b/packages/client/schema-form/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index 0891660b79..1efc9228d4 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 93facc56eb..90b6fc4fa8 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index 5ff21d89b9..513f6cf8a4 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index a21fad9168..bb93c82608 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index 4b7b8daa56..e60fc8262b 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 2e5816437e..648fb0e609 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 5fc53a5fa8..26c056f602 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index dcd139dad5..d2ca1b3318 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-client-ui-model", "description": "Model selection: the /model popupSelect over session.models / session.selectModel", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index fb0ff5a0c3..8eaac3bccf 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index 457511c928..12f1a25509 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 888cc74005..23d03b88d3 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 91d8647a55..dc0c21d31f 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index 80f18e3a2a..172f4fb4f8 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index a66cb41153..b67d12a709 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 138b4e3c88..a65466e2c2 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index 7a135c1bcc..c4022d04d0 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index 0490958e7d..d87425625a 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/client/ui-slash/package.json b/packages/client/ui-slash/package.json index 7e99d22875..46de2a5951 100644 --- a/packages/client/ui-slash/package.json +++ b/packages/client/ui-slash/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index ef809470b5..c9825022e8 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 6c9a178d31..bbae62edb6 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 27f1799c56..1ad1274427 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index 2db30e5d48..eb3b9366ba 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index 6f85a51c8a..4aef2a424b 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 5607c8cc40..7b4ad80acf 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/client/web-react/package.json b/packages/client/web-react/package.json index 5aeae6f06f..3f65de4ad8 100644 --- a/packages/client/web-react/package.json +++ b/packages/client/web-react/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-client-web-react", "description": "Shell-side React glue: createSlotRenderer, SessionProvider, bindSnapshotSelector (uSES bridge), useInvoke", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/client/web/package.json b/packages/client/web/package.json index 348f1ce1cb..e4a1e60136 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index 565541abc8..55e2cbc398 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-code-runtime-worker", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 053e56b54a..57b85721a3 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/compact/command-compact/package.json b/packages/compact/command-compact/package.json index ec043adca7..ff64dba2a6 100644 --- a/packages/compact/command-compact/package.json +++ b/packages/compact/command-compact/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index f407d15188..1f6c2b402e 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json index 5a30f7fc97..45eab7b091 100644 --- a/packages/compact/compact-tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index 2a4c3c9518..abdcd8a090 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-compact", "description": "Abstract compaction service seam (ctx.compact) for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 259c7a9a63..900ec038e3 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferences)", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index d2bf0a1dbe..08bb776c47 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 99959c18f3..6cff253fac 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index 4972796a44..843d038dc5 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-workspace-context", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index e57709585b..8a6341208e 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index fe37f8ca1c..d37d92aede 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/core/agent-tool-mode/package.json b/packages/core/agent-tool-mode/package.json index 8427822784..bb25b4a14c 100644 --- a/packages/core/agent-tool-mode/package.json +++ b/packages/core/agent-tool-mode/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index 5669858fd9..b75f720ad4 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 792a6716e6..e82a8650e5 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/core/session/package.json b/packages/core/session/package.json index ada5c170d1..8fe45b8bed 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 35a270cf9d..3dc65c9391 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 7070431132..6db89c3b0f 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 483fc3d5d0..b29c9d2fbb 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 2b356c197c..f0c9c43694 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index edb7077d63..98dff52be8 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index 7b7faf9c03..bb3190c4be 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index 182c41b8f5..ca5de7a893 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 0e110ec3d4..e21a3b21a2 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 73520f5645..470cb869df 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index 49aec73d1f..a9452b6a22 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index ac901d6a99..e3d72eb3cb 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback producer and human-facing slash command", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index 1401a25597..feb0b621d5 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/fs/fs-policy/package.json b/packages/fs/fs-policy/package.json index 007ef3d529..26273eb031 100644 --- a/packages/fs/fs-policy/package.json +++ b/packages/fs/fs-policy/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 015a572c19..1622fc2621 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index c8445419e1..e14e5f0a15 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index ab8a988ef6..6e8c611782 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index b2070827cb..456f7514af 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 04ad220a3a..7dfd74c7cd 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index daf1ebf924..8a34968cb6 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json index bd315cb2c1..9fdb8e83e1 100644 --- a/packages/goal/goal-session/package.json +++ b/packages/goal/goal-session/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-goal-session", "description": "Race-fenced same-session goal-round driver", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 32ca4d00d9..db2fe9c3c1 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 0b03bb02b6..8c843fe7e8 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index 9bc4fd493f..2f9bf37a0f 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index 3f56d8e59e..9ea624ee83 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 670cd4df5d..d628b7772c 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index 2af6a96466..79ea6ec65b 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 37ac4d7bed..2f567eed26 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 5ce28e9d05..219704e6dd 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index b5e5164500..a553fc2c85 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 04078b9ea7..329e955c54 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index b725cd1001..d3000b1e98 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index c348021815..a8feccefbe 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 73414a5422..9da90e8b20 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index e7a4b4cbf9..0f29d976e6 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 96fd3e6740..2909fda952 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UI surfaces", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/interaction/permission/package.json b/packages/interaction/permission/package.json index 5fad3f9d25..4ec76c4e16 100644 --- a/packages/interaction/permission/package.json +++ b/packages/interaction/permission/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index b7291d4ccc..76b103ca04 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 854e316df9..99f1e82f1f 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/interaction/user-interaction/package.json b/packages/interaction/user-interaction/package.json index c72e3f1298..67022c524b 100644 --- a/packages/interaction/user-interaction/package.json +++ b/packages/interaction/user-interaction/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index d2573696be..3bc2702d1b 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index f7a931d902..06003f4165 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 5f69487147..8baa7e8e5d 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 1a11622738..67a45ccf88 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 61cd66617f..50762fc86f 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index 02fcddd558..0a2181c3de 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index d5993b515e..c4a2385665 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index 7b7dc54dc2..4a23cffe88 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index e5b110e7db..a7f3fc3b90 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index d5c24e3877..1c8b81cc30 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 5a08e6f99d..00a8318ad0 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index cda2faf015..193a9cb45e 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index 82705255dc..9aad0c1502 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-pty-local", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/pty/pty/package.json b/packages/pty/pty/package.json index 9671cfd8ca..75f3b33302 100644 --- a/packages/pty/pty/package.json +++ b/packages/pty/pty/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/pty/tool-bash-persistent/package.json b/packages/pty/tool-bash-persistent/package.json index fdd5688e8e..34d95c6f78 100644 --- a/packages/pty/tool-bash-persistent/package.json +++ b/packages/pty/tool-bash-persistent/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/pty/tool-pty/package.json b/packages/pty/tool-pty/package.json index f3afefdc0a..2db01c4416 100644 --- a/packages/pty/tool-pty/package.json +++ b/packages/pty/tool-pty/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 5af6264b4a..c68cd8532a 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 7636977d6e..d90befdd9e 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 8305b2cc20..6d8d9c722b 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 558588cfa7..9fc9f8a460 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/scaffold/client/package.json b/packages/scaffold/client/package.json index 7e0a4b0540..227e00a1ab 100644 --- a/packages/scaffold/client/package.json +++ b/packages/scaffold/client/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/scaffold/create-sdk/package.json b/packages/scaffold/create-sdk/package.json index 4b40b6b2ca..e61b645303 100644 --- a/packages/scaffold/create-sdk/package.json +++ b/packages/scaffold/create-sdk/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/create-sdk", "description": "Create a DeepSeek Harness SDK project with npm create @deepseek-ai/sdk", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/scaffold/helper/package.json b/packages/scaffold/helper/package.json index f65b51052a..0266daaba1 100644 --- a/packages/scaffold/helper/package.json +++ b/packages/scaffold/helper/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-helper", "description": "Domain model and infrastructure for creating and editing DeepSeek Harness SDK projects", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/scaffold/protocol/package.json b/packages/scaffold/protocol/package.json index c0d6ff1046..feb8784a6d 100644 --- a/packages/scaffold/protocol/package.json +++ b/packages/scaffold/protocol/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/scaffold/scripts/package.json b/packages/scaffold/scripts/package.json index d6ef776d67..e24969d78d 100644 --- a/packages/scaffold/scripts/package.json +++ b/packages/scaffold/scripts/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-scripts", "description": "DeepSeek Harness SDK launcher for start, dev, build, and project configuration", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/scaffold/server/package.json b/packages/scaffold/server/package.json index 457a375b04..6911f4b117 100644 --- a/packages/scaffold/server/package.json +++ b/packages/scaffold/server/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/scaffold/telemetry/package.json b/packages/scaffold/telemetry/package.json index 3ebd894a99..419d8f9ed1 100644 --- a/packages/scaffold/telemetry/package.json +++ b/packages/scaffold/telemetry/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/self-modification/tool-cordis/package.json b/packages/self-modification/tool-cordis/package.json index 5544c84b49..1fc3fcebb1 100644 --- a/packages/self-modification/tool-cordis/package.json +++ b/packages/self-modification/tool-cordis/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index d2ca6da01a..a54c9e0e1b 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 30e6a52593..cdd3d740f5 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 0e8f8c95ee..0da248f48a 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 6a7a07ad13..8a5766641f 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 9960565102..6bc752ed0f 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json index 62bbba8dba..8d6f05cbd8 100644 --- a/packages/session/session-persistence-sqlite/package.json +++ b/packages/session/session-persistence-sqlite/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-session-persistence-sqlite", "description": "SQLite durable session persistence backend for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 94b1455beb..d556ebff73 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index d54b0d18e7..885c293dc9 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index 46445673ab..d17884eadb 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 9a8c4e914f..569e62e70b 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index 2176d02313..d9ac09b6b7 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/session/session-title-all-messages-llm/package.json b/packages/session/session-title-all-messages-llm/package.json index 70c3b4c255..ecd82f5f72 100644 --- a/packages/session/session-title-all-messages-llm/package.json +++ b/packages/session/session-title-all-messages-llm/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/session/session-title-first-message-llm/package.json b/packages/session/session-title-first-message-llm/package.json index 0c934c192b..88e8ddb1ca 100644 --- a/packages/session/session-title-first-message-llm/package.json +++ b/packages/session/session-title-first-message-llm/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 6e5acac31f..7b2c4caacd 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 820df4c70d..84a3b8caaf 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/session/user-id/package.json b/packages/session/user-id/package.json index 5e86cc4a32..fe733ccfd0 100644 --- a/packages/session/user-id/package.json +++ b/packages/session/user-id/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index 5e086b8efe..c66e51274d 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-settings-local", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 23cb60d13c..da6a1cccc0 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index 7df1703c0e..5b23d44f9c 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index 24ce31af02..1cec60bf92 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-skill-local", "description": "Local filesystem skill provider for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 20309664cd..921adf7632 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 07adab663a..25e0f9b2c9 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index fcfacf1dcf..7dd63ce56a 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 3d6cda2186..f425208b8d 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 996069461e..9340d28a37 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index fe5915c2ea..5c6f8836f0 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index 55012ed819..73d8ed9e74 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index 6283822419..4ad22a7946 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index 094814dfa1..b638d392e0 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index a99154af56..155e89ced1 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index d2234104cb..758d30435c 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index a14173e3ee..e44de3fa75 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 58bdd83e13..22d66af4bd 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 57d79a38d4..c0abf8ab3f 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index 15dfe1c321..09fe807748 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index f20b96227c..c865aa5251 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index fc1f2dfb86..070fe2c8cb 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index d1ca31c635..c89d35c727 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index c3f0e530ec..665230bdbd 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-tool-subagent-report", "description": "Child-scoped report tool over ctx.subagents continuations", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index d5d0da52d4..5ba14c54b7 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index d966c0efb8..a9547a9c48 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index f22708c717..a1a4e40865 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index fb4e13ec1e..929ba31708 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/support/agent-loop-testkit/package.json b/packages/support/agent-loop-testkit/package.json index 2bbfa195a7..5ca1382750 100644 --- a/packages/support/agent-loop-testkit/package.json +++ b/packages/support/agent-loop-testkit/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 3ef3a80f41..0f1503b835 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/support/llm-mock-server/package.json b/packages/support/llm-mock-server/package.json index c342697f01..9f5acda6cb 100644 --- a/packages/support/llm-mock-server/package.json +++ b/packages/support/llm-mock-server/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index 0c85caf96f..d199ea5a4f 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index 10dc20f863..0de74f6e60 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index 76deaf9b8d..aa4673b811 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-tasks-local", "description": "Process-local implementation of the DeepSeek Harness background task registry seam", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index 61b8cd1ab8..2e59a68679 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index 38827df4f8..f55816cc82 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index ee57660440..3bd9294687 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index fd9f14c449..117f18f48b 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 998c0ec3a5..4f33f4bc23 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index dacb88764b..67f2611db1 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json index 9a1c362780..ae3fb96a2b 100644 --- a/packages/typert/type-meta/package.json +++ b/packages/typert/type-meta/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-type-meta", "description": "Compiler-independent Remote metadata and TypeRT provider protocols", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index b54b5989cf..5556d4ab96 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 534cb20049..cdf9307e73 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-brand", "description": "Type-only Branded nominal-typing primitive for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/util/environment/package.json b/packages/util/environment/package.json index 113f3b10c9..7eee1c2b1e 100644 --- a/packages/util/environment/package.json +++ b/packages/util/environment/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index a50e0f2c12..81c6c8c771 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/util/paths/package.json b/packages/util/paths/package.json index 749d3e7ac9..2405fc5b65 100644 --- a/packages/util/paths/package.json +++ b/packages/util/paths/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/util/retention/package.json b/packages/util/retention/package.json index ad61896d35..d7eec5489a 100644 --- a/packages/util/retention/package.json +++ b/packages/util/retention/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 0a398aa7a4..2404dd13ff 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 585c5a0e83..2eb44ba59c 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json index cc34f6535d..61948fb142 100644 --- a/packages/web/web-fetch-local/package.json +++ b/packages/web/web-fetch-local/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index cee385654b..bcca1d616a 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 1861465ce9..15e907649d 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 9c821c8dd7..a952c7f83d 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 4d4f787412..6f74c65f2b 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index c2ebc18c31..184f656206 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 0078621f99..125df12935 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index e229c02e37..d085ba235f 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 3cc199028a..e2a6eb21d3 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflows service, run vocabulary, and workflow/* events", "version": "0.0.1", - "private": true, + "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", diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index b33296c228..9c0c5127d5 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -2,7 +2,14 @@ "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, + "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", diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 49ff148922..cdada48fc5 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -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> = { @@ -232,8 +240,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' @@ -241,6 +249,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`) } diff --git a/vendor/cordis/package.json b/vendor/cordis/package.json index ddc49b0655..3428ccbdbc 100644 --- a/vendor/cordis/package.json +++ b/vendor/cordis/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/cordis", "description": "Meta-Framework for Modern JavaScript Applications", "version": "4.0.0-rc.7", - "private": true, + "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", diff --git a/vendor/cosmokit/package.json b/vendor/cosmokit/package.json index db14a695e7..de1b3947bc 100644 --- a/vendor/cosmokit/package.json +++ b/vendor/cosmokit/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/cosmokit", "description": "A collection of common utilities", "version": "1.8.1", - "private": true, + "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 9ddb0a132c..c9f242a7d3 100644 --- a/vendor/group/package.json +++ b/vendor/group/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/cordis-plugin-group", "description": "Nested plugin group for cordis", "version": "1.0.0", - "private": true, + "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", diff --git a/vendor/hmr/package.json b/vendor/hmr/package.json index 4ebaf14959..019c027584 100644 --- a/vendor/hmr/package.json +++ b/vendor/hmr/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/cordis-plugin-hmr", "description": "Hot Module Replacement Plugin for Cordis", "version": "1.0.15", - "private": true, + "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", diff --git a/vendor/include/package.json b/vendor/include/package.json index 3c56bd065b..7fb6c3f36b 100644 --- a/vendor/include/package.json +++ b/vendor/include/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/cordis-plugin-include", "description": "Include files in cordis configurations", "version": "1.0.4", - "private": true, + "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", diff --git a/vendor/loader/package.json b/vendor/loader/package.json index e535334803..aab8c19bf5 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/cordis-plugin-loader", "description": "Plugin loader for cordis", "version": "1.0.0-rc.5", - "private": true, + "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", diff --git a/vendor/logger-console/package.json b/vendor/logger-console/package.json index b1cc3734bb..aeeb81fb50 100644 --- a/vendor/logger-console/package.json +++ b/vendor/logger-console/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/cordis-plugin-logger-console", "description": "Console logger exporter for cordis", "version": "1.0.0", - "private": true, + "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", diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index f2a0c61e5a..e31c6e6251 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/schemastery", "description": "Type driven schema validator", "version": "3.18.0", - "private": true, + "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", diff --git a/vendor/timer/package.json b/vendor/timer/package.json index 9bf741fc51..32f564aa9d 100644 --- a/vendor/timer/package.json +++ b/vendor/timer/package.json @@ -2,7 +2,14 @@ "name": "@deepseek-ai/cordis-plugin-timer", "description": "Timer service for cordis", "version": "1.1.2", - "private": true, + "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", From 35e2601a280f4b2a25a5a2896205b1d94df8d0aa Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:03:15 +0800 Subject: [PATCH 040/113] docs(release): add the English npm release sequences note and its pairing Also corrects the Chinese text where decisions moved after it was written: the release set is every member of packages/, apps/, and vendor/ rather than a dependency closure, because plugins are mounted by name from cordis.yml and a closure misses runtime-required packages; the Landlock packages publish privately with the rest; and the manifests carry repository metadata alongside the access level. --- ...2026-08-10-npm-release-sequences.i18n.yaml | 6 + .../2026-08-10-npm-release-sequences.md | 185 ++++++++++++++++++ .../2026-08-10-npm-release-sequences.zh.md | 14 +- 3 files changed, 199 insertions(+), 6 deletions(-) create mode 100644 .agents/notes/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml create mode 100644 .agents/notes/proposed/process/2026-08-10-npm-release-sequences.md diff --git a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml new file mode 100644 index 0000000000..df41fb5816 --- /dev/null +++ b/.agents/notes/proposed/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/proposed/process/2026-08-10-npm-release-sequences.md +2026-08-10-npm-release-sequences.md: 700d922495c539145dd470fbff84e3e211f6ba4e +2026-08-10-npm-release-sequences.zh.md: 59761eacb1c65d4a38e721a319924bf58c5b423e diff --git a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md new file mode 100644 index 0000000000..700d922495 --- /dev/null +++ b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md @@ -0,0 +1,185 @@ +# Agent Note: Private npm publication as three independent sequences + +Status: proposed + +English | [中文](2026-08-10-npm-release-sequences.zh.md) + +## Problem + +This repository holds three unrelated groups of publishable packages and no channel that sends 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 that already have `landlock-run-release.yml`. 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 sit in the current state. All 217 workspace manifests set `private: true`, which npm refuses to publish. The subtler one is 933 hard-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 excludes `0.0.1-rc.1` too. Those 933 entries have never failed only because the version has never left `0.0.1`. + +The existing `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, but its tarball payload validation and installed-artifact probes are verified parts. + +## Proposal + +### Three independent sequences + +`packages/`, `vendor/`, and `native/` each get 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 whole family, `0.0.x` | `dsh-v` | `release.yml` (new) | +| vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor--v` (one per package) | `release-vendor.yml` (new) | +| native | `native/landlock-run/packages/*` | its own `0.0.x` | `landlock-run-v` | `landlock-run-release.yml` (unchanged) | + +All three publish privately to the `@deepseek-ai` scope on npmjs.com (`npm publish --access restricted`). + +### Versions land in the repository from a local command; CI only checks and uploads + +Each sequence has one `bump and commit` command: derive the target version, write it into the relevant manifests, run `pnpm install --lockfile-only`, self-check immediately, then `git add` the manifests and the lockfile and commit. The published version is therefore readable from the repository, and "which version went out" is never a question. A human creates the tag after the commit merges to master; CI never writes to the repository and needs no write permission. + +The dsh sequence shares one version across the family and accepts `major | minor | patch | x.y.z`. A prerelease version such as `0.0.1-rc.1` drives pack, the installed-artifact probes, and one real private publication end to end first; numbered versions like `0.0.1` and `0.0.2` follow once that passes. The dist-tag decision is the one this repository already makes in `landlock-run-release.yml`: 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 upstream version with its prerelease segment dropped and its patch incremented: + +| 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 | + +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 runs `git diff ..HEAD -- vendor/`; a difference means patch+1, no difference means skip, and a missing tag means the first publication from the table above. The diff considers only paths that reach the tarball (the `files` rules in `scripts/publication-payload.ts`), so editing a comment inside a vendored package does not trigger a release. + +A tag is a commit pointer, not proof of publication — a tag created for a publication that then failed must be recognizable. So bump also asks the registry whether the version that tag names exists, and fails for a human to resolve when it does not, rather than guessing. Querying a private package needs authentication; the check is skipped on a machine that is not logged in and enforced in CI. + +The dependency ranges *inside* the nine vendored packages need no rewrite: `^1.8.1` admits `1.8.2` and `^1.0.0-rc.5` admits `1.0.1`, so patch+1 always lands inside the range. + +### Publication runs only on GitHub, and the registry decides what goes out + +Publication runs only from GitHub Actions; there is no local publication path. That makes the registry check a mandatory CI step instead of something with a bypass for an unauthenticated machine. + +Publish reads no tag and no manifest of "what this release includes". For each member it compares the manifest 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 is the point of the rule: it 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. The dsh sequence behaves identically with one version: the difference is either the whole set or nothing. + +The third state depends on a reproducible build — packing the same commit twice must produce the same bytes. That must be measured, not assumed: if `pnpm run build` embeds absolute paths or timestamps, integrity drifts while content is unchanged and the third state reports a false failure. Before this lands, pack the same commit twice in CI and compare integrity; if it is not reproducible, compare per-file content hashes inside the tarball instead and exclude the fields that drift. + +### Rewrite workspace-internal references to `workspace:^`, once + +Every reference to a workspace member becomes `workspace:^`, so `pnpm pack` substitutes a range that matches the target version: + +| Surface | Count | Effect | +|---|---|---| +| sibling dsh `peerDependencies` | 933 | `0.0.2` and `0.0.1-rc.1` both get a matching range | +| dep / peer / devDep pointing at vendor | 105 + 221 + 218 | no dsh-side rewrite after a vendor patch+1, and no range that goes stale as vendor increments | + +`scripts/check-workspace-constraints.ts` currently asserts that the vendor peer and dev ranges are equal; both become `workspace:^`, so the assertion still holds but its wording changes with it. + +This is what makes "no dependency rewriting at publication time" possible: publication does one thing, which is packing bytes. + +### 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 family description and one workflow lane, not changing the core. + +| Object | Responsibility | +|---|---| +| `ReleaseFamily` | a family's identity: member discovery, version policy, tag naming, publish target. A new release family lands here | +| `ReleaseMember` | one publishable package: directory, manifest, family, position in publish order | +| `VersionPolicy` | where the version comes from. `SharedSemver` (dsh: one version for the family) and `PerPackageChanged` (vendor: change judged by tag, prerelease dropped, patch incremented) | +| `ReleaseSet` | a family's members in topological order over `dependencies`, ties broken by package name for determinism | +| `PackedBundle` | the tarballs plus `publish-order.txt` and its metadata: the only handoff between pack and publish | +| `PublishTarget` | registry, access, dist-tag, credential source. The dist-tag derives from the version's shape | +| `VersionInvariant` | the family's versions agree; publication runs from the family's tag; the tag version equals the package version; the target version is absent from the registry | +| `PayloadInvariant` | tarball content validation, reusing `scripts/publication-payload.ts` | +| `InstalledProbe` | a throwaway consumer outside the repository installs from the tarballs and drives the installed entry with plain Node: `dsh --version`, `dsh --dump-default-config`, and one TUI startup to ready and exit. Moved over from `scripts/publish-npm-baseline.ts` | + +### Workflow shape: pack everything at once, then publish as one set + +The shape comes from the reference flow (`release.yml` and `scripts/pack-release.mjs` in node-addon-require-builtin): the `pack` job walks the whole release set once, running `pnpm --dir pack --pack-destination ` per member, writes `publish-order.txt`, and uploads that directory as **one** artifact; the `publish` job downloads that artifact and runs `npm publish` per entry in `publish-order.txt`. The release set is one unit — half the packages can never reach the registry while the other half is still building. + +The `pack` job carries no credentials: install, verify, build, pack, installed-artifact verification, upload-artifact. The `publish` job sits behind the `npm-publish` environment for human approval, runs `setup-node` and `download-artifact` only, and **neither checks out nor builds** — it uploads the bytes pack produced. Checkout uses `fetch-depth: 0`, because the vendor change judgement reads history and tags. + +The environment is the only brake in the flow: pack has no credentials and can be rehearsed freely, and only publish stops for approval. GitHub needs an `NPM_TOKEN` secret (an automation token with publish rights on the scope) and an `npm-publish` environment (required reviewers, allowed tags limited to `dsh-v*`, `vendor-*`, and `landlock-run-v*`). + +### Pull requests run as far as pack + +The reference flow only has `workflow_dispatch`, so it verifies nothing on a pull request. Here `pull_request` runs the full pack: install, verify, build, pack per member, upload the tarball artifact. What it proves is that this release set still packs completely; it uses no credentials, touches no registry, and runs for pull requests from forks. The artifacts' own correctness is covered by existing tests and is not repeated at this layer. + +The publication path is exercised from master: `push: master` runs the same pack rehearsal as a post-merge regression, and `workflow_dispatch` with `publish: true` performs a real publication from a tag. + +### Repository changes + +| Item | Content | +|---|---| +| release-set manifests | drop `private: true`; add `publishConfig.access: restricted` and `repository` (`git+https://github.com/deepseek-ai/deepseek-harness.git` plus each package's `directory`) | +| release-set boundary | every member of `packages/*/*`, `apps/*`, and `vendor/*`; no smaller selection | +| dependency protocol | workspace-internal references become `workspace:^`, with `check-workspace-constraints.ts` updated | +| root `AGENTS.md` | it states that vendored packages are rescoped and `private: true`; vendor now publishes, so that convention changes | +| `vendor/README.md` | its manifest table records the upstream version, kept distinct from the version we publish | +| the three native packages | `publishConfig.access` moves from `public` to `restricted`; they have never been published, so no anonymous install path exists to preserve | + +### Relationship to the existing proposal + +This Agent Note replaces the version scheme and the release-set boundary in [artifact-first npm baseline publication](2026-08-04-artifact-first-npm-baseline-publication.md): its `--` prerelease versions and `dev-` dist-tag are not adopted, and vendor is no longer excluded from the release set. What both agree on stays: 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 at all: 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.** This is what the reference flow does: unpack the tarballs into a tree and drive it with plain Node. It bypasses version-range resolution, so in principle it cannot prove that 200-odd interdependent packages install from a registry. Running a local registry in CI to cover that layer was proposed and rejected: artifact correctness is already 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. + +**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. The release set is therefore all of `packages/*/*`, `apps/*`, and `vendor/*`; 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 rewriting them to `workspace:^` once, 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. The reference flow leaves bump and commit to local commands and lets CI check and upload. + +## Acceptance criteria + +1. The three sequences release independently: releasing dsh modifies no vendor or native manifest, and the converse holds. +2. `pnpm release:dsh ` performs bump and commit in one command, and the resulting commit carries the family's manifests and the lockfile and self-checks immediately. +3. `pnpm release:vendor` increments the patch only for packages whose tarball content changed since their `vendor--v*` tag, and leaves the manifests of unchanged packages alone. +4. `pull_request` runs the full pack and produces the tarball artifact, with no credentials and no access to a real registry, including for pull requests from forks. +5. `push: master` runs the same pack rehearsal; a real publication can only come from `workflow_dispatch` with `publish: true` from that family's tag. +6. Re-running publish over one artifact republishes no existing version, and when a version exists whose tarball integrity differs, publish fails and names the package. +7. A throwaway consumer outside the repository installs `@deepseek-ai/dsh@0.0.1-rc.1` and drives `--version`, `--dump-default-config`, and one TUI startup with plain Node. +8. Every workspace-internal reference is `workspace:^`, the packed tarballs carry no `workspace:` remnant, and no range points at a version that does not exist. +9. No release-set member sets `private: true`, and each one sets `publishConfig.access: restricted`. + +## Risks + +**Tags drifting from the registry.** A tag created for a publication that then failed makes the next bump treat the package as published. Bump asks the registry whether the version the tag names exists and fails when it does not; on a machine that is not logged in to the private registry that check is skipped, and only the same check in CI catches it. + +**The change judgement depends on visible tags.** A shallow clone, or a checkout without tags, breaks the vendor judgement and degrades it to "publish everything for the first time". `fetch-depth: 0` is a precondition of the judgement, not an optimization. + +**`workspace:^` touches a large surface.** It rewrites 1477 dependency declarations at once. It does not change local resolution — pnpm already resolves from the workspace — but it changes the ranges that go out, and the workspace constraint gate changes with it. + +**The visibility cost of private packages.** After `--access restricted`, every consumer — CI, sandbox e2e, and outside users — needs scope credentials to install. The three native packages move to `restricted` as well; they have never been published, so no existing anonymous install path is cut off. + +**The `repository` organization differs from the one running the workflow.** The release set names `github.com/deepseek-ai/deepseek-harness` while the workflow runs in `deepseek-harness/deepseek-harness`. Token-based publication is unaffected; npm provenance (OIDC) requires the two to agree, so adopting it means either changing `repository` or publishing from the public repository. + +**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. Driving the complete path with `0.0.1-rc.1` first is the only mitigation, which is why numbered versions wait for that to pass. diff --git a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md index 836b5e863b..59761eacb1 100644 --- a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md @@ -124,16 +124,16 @@ publish 不读 tag、不读任何清单,对发布集里每个包比较 manifes | 项 | 内容 | |---|---| -| 发布集 manifest | 去掉 `private: true`,补 `publishConfig`(`access: restricted`、`registry`) | -| 发布集边界 | 一份显式的族与成员清单,脚本据此发现成员并校验闭包内的包都在清单里,不猜 | +| 发布集 manifest | 去掉 `private: true`,补 `publishConfig.access: restricted` 与 `repository`(`git+https://github.com/deepseek-ai/deepseek-harness.git` + 各自 `directory`) | +| 发布集边界 | `packages/*/*` + `apps/*` + `vendor/*` 全部成员,不另挑子集 | | 依赖协议 | workspace 内部引用统一 `workspace:^`,并更新 `check-workspace-constraints.ts` | | 根 `AGENTS.md` | 现在写着 vendored 包是 rescope 过且 `private: true`,vendor 要发布,这条约定要改 | | `vendor/README.md` | manifest 表补记上游版本,与我们发布的版本区分开 | -| native 三包 | `publishConfig.access` 从 `public` 改 `restricted` | +| native 三包 | `publishConfig.access` 从 `public` 改 `restricted`;它们尚未发布过,所以没有匿名安装路径要保 | ### 与既有提案的关系 -本 Note 取代 [以产物为先的 NPM 基线发布](2026-08-04-artifact-first-npm-baseline-publication.zh.md) 中的版本方案与发布集边界两部分:那篇的 `-<时间戳>-<短 SHA>` 预发布版本与 `dev-` dist-tag 不再采用,vendor 也不再排除在发布集之外。两篇一致的部分保留:pack 与 publish 分离、publish 只消费已验证的 tarball、payload 与安装后探针作为发布门。 +本 Note 取代 [以产物为先的 NPM 基线发布](2026-08-04-artifact-first-npm-baseline-publication.md) 中的版本方案与发布集边界两部分:那篇的 `-<时间戳>-<短 SHA>` 预发布版本与 `dev-` dist-tag 不再采用,vendor 也不再排除在发布集之外。两篇一致的部分保留:pack 与 publish 分离、publish 只消费已验证的 tarball、payload 与安装后探针作为发布门。 ## 考虑过的替代方案 @@ -153,7 +153,7 @@ publish 不读 tag、不读任何清单,对发布集里每个包比较 manifes **以 `scripts/publish-npm-baseline.ts` 为基础扩展。** 它是本机发布脚本,把 pack 与 publish 放在同一进程,与「无凭据 pack、受保护 publish」的分离相反。它验证过的零件(payload 校验、已安装产物探针)搬运复用,避免 `pnpm run duplication` 判重复。 -**发布全部 217 个 workspace 包。** 发布集定为入口闭包。`support/`、`examples/` 这类包进 registry 只扩大攻击面与维护面,没有消费方。 +**按入口闭包挑一部分包发。** 从 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-frontend` 沿 `dependencies` 爬得到 156 个包,比全量少 61 个。但本仓的插件是 cordis.yml 按名字挂载的,不是被 import 的:`vendor/cordis-plugin-group` 与 `vendor/cordis-plugin-logger-console` 就落在依赖闭包之外,而它们是运行时必需。照代码依赖挑,漏掉的表现是消费方装完起不来,且要额外证明「没漏任何挂载项」。发布集因此取 `packages/*/*` + `apps/*` + `vendor/*` 全部;私有 scope 下多几个包不对外可见。`python/`、根 `examples/`、`docs/` 与 `website/` 不是发布集成员。 **一个 workflow 用 `family` 输入选择序列。** 两套版本模型塞进一个文件会让 concurrency group、tag 前缀、排练触发条件全部分叉成条件表达式。一族一个文件更短也更好读。 @@ -181,6 +181,8 @@ publish 不读 tag、不读任何清单,对发布集里每个包比较 manifes **`workspace:^` 改动面大。** 一次触及 1477 处依赖声明。它不改变本机解析行为(pnpm 本来就从 workspace 解析),但会改变发布出去的范围写法,且要同步更新 workspace 约束门。 -**私有包的可见性代价。** `--access restricted` 之后,任何消费方(含 CI、沙箱 e2e、外部使用者)都必须持有 scope 凭据才能安装。native 三包从 public 转 restricted 会切断现有匿名安装路径。 +**私有包的可见性代价。** `--access restricted` 之后,任何消费方(含 CI、沙箱 e2e、外部使用者)都必须持有 scope 凭据才能安装。native 三包一并转 `restricted`;它们尚未发布过,因此没有既有的匿名安装路径被切断。 + +**`repository` 指向的组织与运行 workflow 的组织不一致。** 发布集写的是 `github.com/deepseek-ai/deepseek-harness`,而 workflow 跑在 `deepseek-harness/deepseek-harness`。用 token 发布不受影响;一旦改用 npm provenance(OIDC),npm 会要求二者一致,届时要么改 `repository`,要么从公开仓库发布。 **首发一次性放大。** vendor 首发九包、dsh 首发全闭包,任何 payload 缺陷都会在同一次发布里暴露。用 `0.0.1-rc.1` 先跑一遍完整链路是唯一的缓解手段,正式版本号留给验证通过之后。 From d4128ae57f8c5181500aeaa7d81e097310980e4a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:03:15 +0800 Subject: [PATCH 041/113] chore(release): satisfy knip for the release scripts The release scripts spawn tar to read a packed tarball, and resolving a family by id is the only entry point into the family list. --- knip.json | 1 + scripts/release/families.ts | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/knip.json b/knip.json index 2f755dbb5f..44f45da1ad 100644 --- a/knip.json +++ b/knip.json @@ -9,6 +9,7 @@ "musl-gcc", "python3", "sandbox-exec", + "tar", "taskkill", "where.exe" ], diff --git a/scripts/release/families.ts b/scripts/release/families.ts index e989e54525..b02c21cb1e 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -253,7 +253,7 @@ class VendorFamily extends ReleaseFamily { } /** Every release family this module owns, in workflow order. */ -export function releaseFamilies(): readonly ReleaseFamily[] { +function releaseFamilies(): readonly ReleaseFamily[] { return [new DshFamily(), new VendorFamily()] } From 2c85c484d3b205043e73620dc9e16c0adfb38813 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:16:45 +0800 Subject: [PATCH 042/113] build(release): reference workspace members through the workspace protocol 1504 hand-written ranges pointing at workspace members become workspace:^, so pnpm pack substitutes each member's real version at publication: sibling peerDependencies follow the family version instead of being pinned at ^0.0.1, and a reference to a vendored package follows that package's own line. Without this, publishing 0.0.2 ships peer ranges naming a version that does not exist, and 0.0.1-rc.1 does not satisfy ^0.0.1 either. It also retires ranges that had gone stale against the workspace: ^4.0.0-rc.6 for a 4.0.0-rc.7 checkout, ^3.17.0 for schemastery 3.18.0. workspace:* stays where an exact published version is the point, which is how the Landlock entry pins its platform packages. A workspace constraint now requires the protocol, so a new package cannot reintroduce a hand-written range. The same constraint caught packages/boot/cmdline arriving on master without the publishable trio, which this change completes. --- apps/cli/package.json | 2 +- packages/acp/acp/package.json | 14 +- packages/api/gateway/package.json | 10 +- packages/api/remotes/package.json | 16 +- .../attachment/attachment-local/package.json | 12 +- packages/attachment/attachment/package.json | 8 +- packages/bash/bash-env/package.json | 16 +- packages/bash/bash-local/package.json | 14 +- packages/bash/bash-sandbox/package.json | 14 +- packages/bash/bash/package.json | 10 +- packages/bash/pwsh-local/package.json | 14 +- packages/bash/pwsh-sandbox/package.json | 14 +- packages/bash/tool-bash/package.json | 28 +- packages/bash/tool-pwsh/package.json | 28 +- packages/boot/app-boot/package.json | 20 +- packages/boot/cmdline/package.json | 17 +- packages/bundle/base/package.json | 6 +- packages/bundle/headless/package.json | 18 +- packages/bundle/web-app/package.json | 14 +- packages/client/connection/package.json | 10 +- packages/client/hmr/package.json | 14 +- packages/client/locale/package.json | 16 +- packages/client/modules/package.json | 6 +- packages/client/runtime/package.json | 12 +- packages/client/schema-form/package.json | 8 +- packages/client/test-runtime/package.json | 14 +- packages/client/ui-agent-preset/package.json | 22 +- packages/client/ui-command/package.json | 20 +- packages/client/ui-conversation/package.json | 36 +- packages/client/ui-deliverables/package.json | 14 +- packages/client/ui-goal/package.json | 20 +- packages/client/ui-layout/package.json | 12 +- packages/client/ui-model/package.json | 22 +- packages/client/ui-models/package.json | 18 +- packages/client/ui-permission/package.json | 24 +- packages/client/ui-plan/package.json | 20 +- packages/client/ui-primitives/package.json | 6 +- packages/client/ui-question/package.json | 8 +- .../client/ui-settings-general/package.json | 22 +- packages/client/ui-settings/package.json | 12 +- packages/client/ui-sidebar/package.json | 14 +- packages/client/ui-skill/package.json | 20 +- packages/client/ui-slash/package.json | 14 +- packages/client/ui-slots/package.json | 6 +- packages/client/ui-subagent/package.json | 22 +- packages/client/ui-theme/package.json | 18 +- packages/client/ui-tool/package.json | 16 +- packages/client/ui-trajectory/package.json | 10 +- packages/client/ui-workspace/package.json | 14 +- packages/client/web-react/package.json | 6 +- packages/client/web/package.json | 8 +- .../code-runtime-worker/package.json | 14 +- .../code-runtime/code-runtime/package.json | 6 +- packages/compact/command-compact/package.json | 10 +- packages/compact/compact-basic/package.json | 22 +- .../compact-tool-result-prune/package.json | 16 +- packages/compact/compact/package.json | 14 +- .../context/session-reference/package.json | 20 +- packages/context/time-context/package.json | 12 +- packages/context/tmux-context/package.json | 14 +- .../context/workspace-context/package.json | 20 +- .../core/agent-default-model/package.json | 14 +- packages/core/agent-loop/package.json | 22 +- packages/core/agent-tool-mode/package.json | 10 +- packages/core/agent/package.json | 16 +- packages/core/scope/package.json | 6 +- packages/core/session/package.json | 14 +- packages/core/system-prompt/package.json | 12 +- packages/core/tools/package.json | 22 +- .../credentials-local/package.json | 16 +- packages/credentials/credentials/package.json | 8 +- packages/e2b/e2b/package.json | 8 +- packages/e2b/fs-e2b/package.json | 10 +- packages/e2b/subprocess-e2b/package.json | 14 +- packages/examples/acp-demo/package.json | 32 +- .../examples/agent-spine-demo/package.json | 52 +- packages/examples/jsonrpc-demo/package.json | 6 +- .../feedback/command-feedback/package.json | 12 +- packages/fs/fs-local/package.json | 10 +- packages/fs/fs-policy/package.json | 8 +- packages/fs/fs-sandbox/package.json | 14 +- packages/fs/fs/package.json | 12 +- packages/fs/tool-fs-search/package.json | 24 +- packages/fs/tool-fs/package.json | 24 +- .../fs/tool-str-replace-editor/package.json | 16 +- packages/goal/command-goal/package.json | 10 +- packages/goal/goal-session/package.json | 14 +- packages/goal/goal/package.json | 22 +- packages/goal/tool-goal/package.json | 20 +- packages/guard/repeat-tool-guard/package.json | 12 +- packages/guard/timeout-policy/package.json | 12 +- packages/hooks/hook-protocol/package.json | 10 +- packages/hooks/hooks-claude/package.json | 22 +- packages/hooks/hooks-codex/package.json | 20 +- packages/host/apiproxy/package.json | 10 +- .../host/directory-picker-auto/package.json | 14 +- .../host/directory-picker-browse/package.json | 18 +- .../host/directory-picker-native/package.json | 12 +- packages/host/directory-picker/package.json | 6 +- packages/host/frontend-static/package.json | 10 +- packages/host/webserver/package.json | 8 +- packages/interaction/commands/package.json | 14 +- packages/interaction/permission/package.json | 24 +- .../interaction/tool-ask-user/package.json | 12 +- .../interaction/user-approval/package.json | 20 +- .../interaction/user-interaction/package.json | 10 +- packages/llm/llm-deepseek/package.json | 18 +- packages/llm/llm-pi-ai/package.json | 20 +- packages/llm/llm-retry/package.json | 18 +- packages/llm/llm/package.json | 14 +- packages/llm/token-meter/package.json | 16 +- packages/lsp/lsp-local/package.json | 20 +- packages/lsp/lsp/package.json | 10 +- packages/lsp/tool-lsp/package.json | 18 +- packages/mcp/mcp-client/package.json | 14 +- packages/plan/plan-mode/package.json | 22 +- packages/preset/agent-presets/package.json | 22 +- packages/preset/persona/package.json | 10 +- packages/pty/pty-local/package.json | 20 +- packages/pty/pty/package.json | 10 +- .../pty/tool-bash-persistent/package.json | 16 +- packages/pty/tool-pty/package.json | 22 +- packages/sandbox/sandbox-local/package.json | 14 +- packages/sandbox/sandbox-policy/package.json | 16 +- .../sandbox/sandbox-windows-acl/package.json | 6 +- packages/sandbox/sandbox/package.json | 10 +- packages/scaffold/client/package.json | 12 +- packages/scaffold/create-sdk/package.json | 6 +- packages/scaffold/helper/package.json | 10 +- packages/scaffold/protocol/package.json | 12 +- packages/scaffold/scripts/package.json | 6 +- packages/scaffold/server/package.json | 22 +- packages/scaffold/telemetry/package.json | 10 +- .../tool-cordis/package.json | 14 +- .../session-query-sqlite/package.json | 14 +- .../session-query/session-query/package.json | 16 +- .../tool-session-query/package.json | 20 +- .../session-checkpoint-policy/package.json | 16 +- .../session-persistence-jsonl/package.json | 12 +- .../session-persistence-sqlite/package.json | 12 +- .../session/session-persistence/package.json | 12 +- .../session-projection-cache/package.json | 16 +- .../session/session-projection/package.json | 8 +- .../session-telemetry-otel/package.json | 18 +- .../session/session-telemetry/package.json | 10 +- .../package.json | 16 +- .../package.json | 16 +- .../session/session-title-llm/package.json | 16 +- packages/session/session-title/package.json | 16 +- packages/session/user-id/package.json | 10 +- packages/settings/settings-local/package.json | 14 +- packages/settings/settings/package.json | 12 +- packages/skill/skill-badge/package.json | 8 +- packages/skill/skill-local/package.json | 14 +- packages/skill/skill/package.json | 12 +- packages/skill/tool-skill/package.json | 16 +- packages/spill/spill-local/package.json | 10 +- packages/spill/spill-policy/package.json | 18 +- packages/spill/spill/package.json | 12 +- packages/storage/storage-domain/package.json | 10 +- packages/storage/storage-json/package.json | 10 +- packages/storage/storage-sqlite/package.json | 10 +- packages/storage/storage/package.json | 6 +- packages/subagent/subagent-acp/package.json | 22 +- .../subagent-claude-code/package.json | 18 +- packages/subagent/subagent-codex/package.json | 22 +- .../subagent/subagent-dsh-sdk/package.json | 22 +- packages/subagent/subagent-fork/package.json | 18 +- .../subagent/subagent-inprocess/package.json | 22 +- packages/subagent/subagent-spawn/package.json | 14 +- packages/subagent/subagent/package.json | 34 +- .../tool-subagent-control/package.json | 14 +- .../tool-subagent-report/package.json | 14 +- packages/subagent/tool-subagent/package.json | 20 +- .../subprocess/subprocess-local/package.json | 10 +- packages/subprocess/subprocess/package.json | 6 +- packages/support/acp-snapshot/package.json | 8 +- .../support/agent-loop-testkit/package.json | 16 +- packages/support/invariants/package.json | 6 +- packages/support/llm-mock-server/package.json | 6 +- packages/support/llm-replay/package.json | 12 +- packages/support/loader-smoke/package.json | 12 +- packages/tasks/tasks-local/package.json | 14 +- packages/tasks/tasks/package.json | 12 +- packages/tasks/tool-tasks/package.json | 20 +- packages/todo/tool-todo/package.json | 16 +- packages/typert/generator/package.json | 6 +- packages/typert/loader/package.json | 12 +- packages/typert/registry/package.json | 6 +- packages/typert/type-meta/package.json | 6 +- packages/util/atomic-write/package.json | 6 +- packages/util/brand/package.json | 6 +- packages/util/environment/package.json | 6 +- packages/util/native-command/package.json | 6 +- packages/util/paths/package.json | 6 +- packages/util/retention/package.json | 6 +- packages/util/timeout/package.json | 6 +- packages/web/tool-web/package.json | 16 +- packages/web/web-fetch-local/package.json | 12 +- packages/web/web-search-deepseek/package.json | 18 +- packages/web/web-search-exa/package.json | 12 +- .../web/web-search-perplexity/package.json | 12 +- packages/web/web/package.json | 10 +- packages/workflow/tool-ralph/package.json | 20 +- packages/workflow/tool-workflow/package.json | 18 +- .../workflow-workerthread/package.json | 22 +- packages/workflow/workflow/package.json | 14 +- packages/workspace/workspace/package.json | 16 +- pnpm-lock.yaml | 460 +++++++++--------- scripts/check-workspace-constraints.ts | 33 +- vendor/cordis/package.json | 6 +- vendor/group/package.json | 4 +- vendor/hmr/package.json | 8 +- vendor/include/package.json | 6 +- vendor/loader/package.json | 4 +- vendor/logger-console/package.json | 6 +- vendor/schemastery/package.json | 2 +- vendor/timer/package.json | 4 +- 218 files changed, 1774 insertions(+), 1736 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index 3155032205..71f64a0f41 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -72,7 +72,7 @@ "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "commander": "^15.0.0", - "@deepseek-ai/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/packages/acp/acp/package.json b/packages/acp/acp/package.json index c5564b0b2f..5293a94e5d 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -33,14 +33,14 @@ "license": "BSD-3-Clause", "dependencies": { "@agentclientprotocol/sdk": "0.25.1", - "@deepseek-ai/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", - "@deepseek-ai/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:^", @@ -51,6 +51,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index 10a315302a..bd90451fdc 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -59,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "zod": "^4.4.3" } } diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 8a31f138e3..0a96fb2239 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -53,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", - "@deepseek-ai/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:^", @@ -68,6 +68,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index 4219c40d30..c8804218f1 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -22,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", - "@deepseek-ai/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": { - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index ad212f3933..9d58dccdf6 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -22,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/bash-env/package.json b/packages/bash/bash-env/package.json index 8983029c0d..cde53b2bd1 100644 --- a/packages/bash/bash-env/package.json +++ b/packages/bash/bash-env/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -50,6 +50,6 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index f147b01f72..85eb6f2655 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index b1d1b4a13a..e77dba32e5 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -32,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", - "@deepseek-ai/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:^", @@ -47,7 +47,7 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/node-addon-landlock-run": "workspace:*" } } diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 320f077f28..8d22d657fe 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -32,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/pwsh-local/package.json b/packages/bash/pwsh-local/package.json index a3b5d6aa7d..5e58a5cd3f 100644 --- a/packages/bash/pwsh-local/package.json +++ b/packages/bash/pwsh-local/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-bash": "workspace:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/pwsh-sandbox/package.json b/packages/bash/pwsh-sandbox/package.json index 727496274b..12669329c4 100644 --- a/packages/bash/pwsh-sandbox/package.json +++ b/packages/bash/pwsh-sandbox/package.json @@ -32,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", - "@deepseek-ai/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:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-sandbox-local": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index 6440e990b0..a3d878e8e2 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -68,6 +68,6 @@ "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json index b06df9a992..5188891aea 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -65,6 +65,6 @@ "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index f47611040d..4139847364 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -35,15 +35,15 @@ "js-yaml": "^4.2.0" }, "peerDependencies": { - "@deepseek-ai/cordis-plugin-group": "^1.0.0", - "@deepseek-ai/cordis-plugin-hmr": "^1.0.15", - "@deepseek-ai/cordis-plugin-include": "^1.0.4", - "@deepseek-ai/cordis-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", - "@deepseek-ai/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": { "@deepseek-ai/cordis-plugin-hmr": { @@ -61,6 +61,6 @@ "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@types/js-yaml": "^4.0.9", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 28647bfc24..131c254af0 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -2,7 +2,14 @@ "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", - "private": true, + "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", @@ -25,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@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": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index c2547f9aae..dc54c75012 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -122,11 +122,11 @@ "@deepseek-ai/dsh-workspace-context": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 4bdd04efd9..7778b7be8b 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -46,17 +46,17 @@ "dependencies": { "@deepseek-ai/dsh-cmdline": "workspace:^", "@deepseek-ai/dsh-code-runtime-worker": "workspace:^", - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0" }, "peerDependencies": { - "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", - "@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", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@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:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -65,6 +65,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index c9ec594f28..cde4fa1013 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -87,21 +87,21 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-storage-json": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "commander": "^15.0.0" }, "peerDependencies": { - "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-bash-env": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@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:^" }, "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": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index f03afb8eda..1b9481db93 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -44,7 +44,7 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "ws": "^8.21.0" }, "files": [ @@ -54,14 +54,14 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-host-webserver": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/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", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 82d8cb18e1..3dad86d07a 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -38,21 +38,21 @@ }, "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/cordis-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", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@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:^" }, "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": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index f845feacc9..d44e8ee529 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -41,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", - "@deepseek-ai/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": { @@ -55,12 +55,12 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index e980619017..e93cd2951d 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -45,7 +45,7 @@ "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", @@ -54,7 +54,7 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 168e916223..87e259f64c 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -60,10 +60,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", - "@deepseek-ai/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:^", @@ -71,8 +71,8 @@ "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/schema-form/package.json b/packages/client/schema-form/package.json index 30e0c24353..35d80feba6 100644 --- a/packages/client/schema-form/package.json +++ b/packages/client/schema-form/package.json @@ -27,15 +27,15 @@ }, "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index 1efc9228d4..54088ded59 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -32,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", - "@deepseek-ai/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" }, @@ -49,7 +49,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/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-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 90b6fc4fa8..77133dac21 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -47,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", - "@deepseek-ai/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": { @@ -71,7 +71,7 @@ "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index 513f6cf8a4..d6b015eb96 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -49,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", - "@deepseek-ai/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": { @@ -71,7 +71,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index bb93c82608..f3dd749dde 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -48,25 +48,25 @@ "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", "clsx": "^2.0.0", - "@deepseek-ai/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", - "@deepseek-ai/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": { @@ -93,7 +93,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index e60fc8262b..a5a82a833f 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -48,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", - "@deepseek-ai/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:^", @@ -63,7 +63,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index 648fb0e609..d1ed34e43c 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -46,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", - "@deepseek-ai/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": { @@ -69,7 +69,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "@deepseek-ai/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-layout/package.json b/packages/client/ui-layout/package.json index 26c056f602..879100dbe6 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -44,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", - "@deepseek-ai/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": { @@ -58,7 +58,7 @@ "@deepseek-ai/dsh-client-ui-theme": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index d2ca1b3318..b8f411cdc7 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -45,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", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "devDependencies": { @@ -70,7 +70,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "clsx": "^2.1.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index 8eaac3bccf..0a7b6bbae8 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.json @@ -45,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", - "@deepseek-ai/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": { @@ -67,7 +67,7 @@ "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index 12f1a25509..880666ecad 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -46,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", - "@deepseek-ai/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": { @@ -72,7 +72,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-permission": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 23d03b88d3..52d8e9fffd 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -45,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", - "@deepseek-ai/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": { @@ -68,7 +68,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-plan-mode": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index dc0c21d31f..732321b932 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -52,7 +52,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", @@ -60,7 +60,7 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index 172f4fb4f8..3153f8fd69 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -53,9 +53,9 @@ "react": "^18.2.0" }, "peerDependencies": { - "@deepseek-ai/dsh-client-locale": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/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:^", @@ -65,7 +65,7 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index b67d12a709..8eee8eab62 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -47,18 +47,18 @@ "license": "BSD-3-Clause", "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/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", - "@deepseek-ai/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": { @@ -72,7 +72,7 @@ "@deepseek-ai/dsh-client-web-react": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index a65466e2c2..37c1e1607c 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -47,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", - "@deepseek-ai/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" }, @@ -64,7 +64,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react-dom": "~18.3.0", "@types/react": "~18.3.1", - "@deepseek-ai/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-sidebar/package.json b/packages/client/ui-sidebar/package.json index c4022d04d0..bffbbfd54c 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -48,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", - "@deepseek-ai/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": { @@ -65,7 +65,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index d87425625a..c1ee7720bb 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -46,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", - "@deepseek-ai/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": { @@ -69,7 +69,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "@deepseek-ai/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-slash/package.json b/packages/client/ui-slash/package.json index 46de2a5951..00b5bd1f9a 100644 --- a/packages/client/ui-slash/package.json +++ b/packages/client/ui-slash/package.json @@ -47,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", - "@deepseek-ai/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": { @@ -63,7 +63,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index c9825022e8..54b3d64925 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -29,7 +29,7 @@ "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", @@ -37,7 +37,7 @@ "lib/types/**/*.d.ts" ], "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index bbae62edb6..997bfadcf6 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -50,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", - "@deepseek-ai/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:^", @@ -73,7 +73,7 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 1ad1274427..8d22586ffd 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -43,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", - "@deepseek-ai/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": { @@ -60,7 +60,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ @@ -77,6 +77,6 @@ "dependencies": { "@deepseek-ai/dsh-settings": "workspace:^", "clsx": "^2.0.0", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index eb3b9366ba..c0465db10c 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -48,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", - "@deepseek-ai/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": { @@ -69,7 +69,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@testing-library/react": "^16.1.0", "@types/react": "~18.3.1", - "@deepseek-ai/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/package.json b/packages/client/ui-trajectory/package.json index 4aef2a424b..c50633c2c1 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -48,10 +48,10 @@ "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", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-client-runtime": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-client-ui-primitives": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0", "react-dom": "^18.2.0" }, @@ -63,7 +63,7 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/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-workspace/package.json b/packages/client/ui-workspace/package.json index 7b4ad80acf..206e037bc1 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -49,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", - "@deepseek-ai/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": { @@ -67,7 +67,7 @@ "@deepseek-ai/dsh-client-ui-slots": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "files": [ diff --git a/packages/client/web-react/package.json b/packages/client/web-react/package.json index 3f65de4ad8..1fe9231134 100644 --- a/packages/client/web-react/package.json +++ b/packages/client/web-react/package.json @@ -32,13 +32,13 @@ "use-sync-external-store": "1.2.0" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/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", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "files": [ "lib/index.js", diff --git a/packages/client/web/package.json b/packages/client/web/package.json index e4a1e60136..eff725c83d 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -43,13 +43,13 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", "@types/react-dom": "~18.3.0", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "typescript": "^6.0.3" }, "peerDependencies": { - "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/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/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index 55e2cbc398..bacaca170a 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -36,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", - "@deepseek-ai/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": { - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 57b85721a3..89db4e6d88 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/compact/command-compact/package.json b/packages/compact/command-compact/package.json index ff64dba2a6..3b36536a34 100644 --- a/packages/compact/command-compact/package.json +++ b/packages/compact/command-compact/package.json @@ -32,10 +32,10 @@ ], "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", - "@deepseek-ai/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": { "@deepseek-ai/cordis-plugin-include": "workspace:^", @@ -46,6 +46,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 1f6c2b402e..941e3aa32d 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -32,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", - "@deepseek-ai/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": { @@ -48,7 +48,7 @@ } }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", @@ -65,6 +65,6 @@ "@deepseek-ai/dsh-token-meter": "workspace:^", "@deepseek-ai/dsh-compact-tool-result-prune": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json index 45eab7b091..21951f5750 100644 --- a/packages/compact/compact-tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -32,15 +32,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-token-meter": "^0.0.1", - "@deepseek-ai/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-token-meter": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", @@ -50,6 +50,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-token-meter": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index abdcd8a090..18879ad5b5 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -41,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", - "@deepseek-ai/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:^", @@ -54,6 +54,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 900ec038e3..4c7c7af3f8 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -32,17 +32,17 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/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", - "@deepseek-ai/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:^", @@ -52,6 +52,6 @@ "@deepseek-ai/dsh-retention": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 08bb776c47..6956bf48e0 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -32,13 +32,13 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/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", - "@deepseek-ai/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:^", @@ -50,6 +50,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 6cff253fac..241661250f 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -32,14 +32,14 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/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", - "@deepseek-ai/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:^", @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index 843d038dc5..236009f90d 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -32,17 +32,17 @@ ], "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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -58,6 +58,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 8a6341208e..8826a9fcc4 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -32,20 +32,20 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index d37d92aede..9f37505853 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -31,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -54,6 +54,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/agent-tool-mode/package.json b/packages/core/agent-tool-mode/package.json index bb25b4a14c..a47dbd1434 100644 --- a/packages/core/agent-tool-mode/package.json +++ b/packages/core/agent-tool-mode/package.json @@ -32,12 +32,12 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/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:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index b75f720ad4..b4fe30f2e5 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -37,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", - "@deepseek-ai/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:^", @@ -53,6 +53,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index e82a8650e5..53bd185aa5 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 8fe45b8bed..40508e9a64 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -41,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", - "@deepseek-ai/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:^", @@ -55,6 +55,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index 3dc65c9391..e219dfa37a 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index 6db89c3b0f..e02c4afaef 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -41,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -63,6 +63,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index b29c9d2fbb..004c5caec4 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -32,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", - "@deepseek-ai/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", - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "yaml": "^2.9.0" }, "devDependencies": { @@ -50,6 +50,6 @@ "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index f0c9c43694..8cc0d61e40 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -32,13 +32,13 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index 98dff52be8..fb47ba6c83 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -32,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "e2b": "2.29.1", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index bb3190c4be..47c026eaf2 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -32,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index ca5de7a893..b7be6db8be 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index e21a3b21a2..1c7763482c 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -40,20 +40,20 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/cordis-plugin-include": "^1.0.4", - "@deepseek-ai/cordis-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", - "@deepseek-ai/cordis": "^4.0.0-rc.7", - "@deepseek-ai/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": { "@deepseek-ai/cordis-plugin-include": "workspace:^", @@ -70,7 +70,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7", - "@deepseek-ai/schemastery": "^3.17.0" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 470cb869df..3bfac5084c 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -32,30 +32,30 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/cordis-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", - "@deepseek-ai/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": { "@deepseek-ai/cordis-plugin-timer": "workspace:^", @@ -92,9 +92,9 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workspace-context": "workspace:^", "@deepseek-ai/node-addon-landlock-run": "workspace:*", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index a9452b6a22..ac6b0f05ee 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -48,11 +48,11 @@ "@deepseek-ai/dsh-app-boot": "workspace:^" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index e3d72eb3cb..afdba48641 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -32,11 +32,11 @@ ], "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", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-commands": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-session": "workspace:^", + "@deepseek-ai/dsh-user-id": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-user-id": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index feb0b621d5..c79a13a9d2 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -32,18 +32,18 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/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", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-fs": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/fs-policy/package.json b/packages/fs/fs-policy/package.json index 26273eb031..39b6e0b6f0 100644 --- a/packages/fs/fs-policy/package.json +++ b/packages/fs/fs-policy/package.json @@ -32,14 +32,14 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-fs": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 1622fc2621..610724a528 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -32,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", - "@deepseek-ai/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:^", @@ -45,6 +45,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-sandbox-policy": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index e14e5f0a15..fa97204f47 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -32,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 6e8c611782..8a6a50f1b3 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -33,19 +33,19 @@ "license": "BSD-3-Clause", "dependencies": { "@vscode/ripgrep": "^1.18.0", - "@deepseek-ai/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", - "@deepseek-ai/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:^", @@ -59,6 +59,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 456f7514af..1e4ef4fbf8 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -33,19 +33,19 @@ "license": "BSD-3-Clause", "dependencies": { "diff": "^9.0.0", - "@deepseek-ai/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", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@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:^", @@ -63,6 +63,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 7dfd74c7cd..2a3b18bfdc 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -31,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -54,6 +54,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index 8a34968cb6..bac6e02048 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -32,10 +32,10 @@ ], "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", - "@deepseek-ai/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": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -45,6 +45,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json index 9fdb8e83e1..a43a4b1169 100644 --- a/packages/goal/goal-session/package.json +++ b/packages/goal/goal-session/package.json @@ -32,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", - "@deepseek-ai/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:^", @@ -49,6 +49,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index db2fe9c3c1..5ae59a8093 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -55,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.17.2", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { @@ -79,6 +79,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 8c843fe7e8..309e020842 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -32,17 +32,17 @@ ], "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", - "@deepseek-ai/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/dsh-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -53,6 +53,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index 2f9bf37a0f..fbb6a39192 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -32,13 +32,13 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/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", - "@deepseek-ai/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:^", @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index 9ea624ee83..ab07cc70e0 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -32,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index d628b7772c..7d842222e0 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -32,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index 79ea6ec65b..a41e2e3b4d 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -32,18 +32,18 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/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", - "@deepseek-ai/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:^", @@ -60,6 +60,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 2f567eed26..4ff3f3dc38 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -32,17 +32,17 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/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", - "@deepseek-ai/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:^", @@ -58,6 +58,6 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index 219704e6dd..c6da4ab3ae 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -69,13 +69,13 @@ "@deepseek-ai/dsh-user-approval": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", "@deepseek-ai/dsh-workspace": "workspace:^", - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-agent-presets": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/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:^", @@ -84,6 +84,6 @@ "@deepseek-ai/dsh-storage-domain": "workspace:^", "@deepseek-ai/dsh-type-meta": "workspace:^", "@deepseek-ai/dsh-typert-registry": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index a553fc2c85..bea692eedc 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -32,12 +32,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/cordis-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", - "@deepseek-ai/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": { "@deepseek-ai/cordis-plugin-include": "workspace:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-host-directory-picker-native": "workspace:^", "@deepseek-ai/dsh-host-webserver": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 329e955c54..224f73de29 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -39,16 +39,16 @@ "dependencies": { "@deepseek-ai/dsh-host-directory-picker": "workspace:^", "clsx": "^2.0.0", - "@deepseek-ai/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", - "@deepseek-ai/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": { @@ -60,7 +60,7 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "react": "^18.2.0" }, "dsh": { diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index d3000b1e98..acf9278c89 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -47,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", - "@deepseek-ai/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": { @@ -60,7 +60,7 @@ "@deepseek-ai/dsh-client-ui-workspace": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@types/react": "~18.3.1", - "@deepseek-ai/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/package.json b/packages/host/directory-picker/package.json index a8feccefbe..afad024fa2 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index 9da90e8b20..de7ceb9cf2 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -32,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-host-webserver": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-host-webserver": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@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": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 0f29d976e6..48378f5ff9 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -32,14 +32,14 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/cordis": "^4.0.0-rc.7", - "@deepseek-ai/dsh-invariants": "^0.0.1" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^" } } diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 2909fda952..8e527f64be 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -41,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", - "@deepseek-ai/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:^", @@ -54,6 +54,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/permission/package.json b/packages/interaction/permission/package.json index 4ec76c4e16..85c959315d 100644 --- a/packages/interaction/permission/package.json +++ b/packages/interaction/permission/package.json @@ -41,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { @@ -66,6 +66,6 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index 76b103ca04..b0ffe378ee 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -32,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", - "@deepseek-ai/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:^", @@ -45,6 +45,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 99f1e82f1f..892dcbcf87 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -37,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -57,6 +57,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/interaction/user-interaction/package.json b/packages/interaction/user-interaction/package.json index 67022c524b..cd0336ae5c 100644 --- a/packages/interaction/user-interaction/package.json +++ b/packages/interaction/user-interaction/package.json @@ -37,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 3bc2702d1b..8baa42f0c9 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -32,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", - "@deepseek-ai/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", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-credentials": "workspace:^", @@ -51,6 +51,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 06003f4165..5427a26277 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -32,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", - "@deepseek-ai/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", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-attachment": "workspace:^", @@ -54,6 +54,6 @@ "@deepseek-ai/dsh-llm-deepseek": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 8baa7e8e5d..556d42abf9 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -36,16 +36,16 @@ ], "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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -64,6 +64,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 67a45ccf88..9341670dd1 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -45,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", - "@deepseek-ai/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": { - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 50762fc86f..0755e6842a 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -37,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { @@ -54,6 +54,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index 0a2181c3de..9cdc20a6c7 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -54,7 +54,7 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/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/package.json b/packages/lsp/lsp/package.json index c4a2385665..41b5f12e42 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -32,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index 4a23cffe88..ab5b9603e8 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -56,6 +56,6 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index a7f3fc3b90..1a12f7c674 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -32,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-subprocess": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/dsh-subprocess": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "@modelcontextprotocol/sdk": "^1.12.0", - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { @@ -50,6 +50,6 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@modelcontextprotocol/server-everything": "^2026.7.4", "@modelcontextprotocol/server-filesystem": "^2026.7.4", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index 1c8b81cc30..c8c228e00a 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -41,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", - "@deepseek-ai/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": { @@ -72,6 +72,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index 00a8318ad0..c5fc5eb165 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -32,19 +32,19 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/cordis-plugin-include": "^1.0.4", - "@deepseek-ai/cordis-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", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "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/cordis": "workspace:^" }, "dependencies": { "js-yaml": "^4.1.0", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", @@ -61,6 +61,6 @@ "@deepseek-ai/dsh-settings-local": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index 193a9cb45e..db3121cdaa 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -32,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-system-prompt": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-system-prompt": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index 9aad0c1502..f7f84504bd 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -53,6 +53,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/pty/pty/package.json b/packages/pty/pty/package.json index 75f3b33302..ad874ee876 100644 --- a/packages/pty/pty/package.json +++ b/packages/pty/pty/package.json @@ -32,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/pty/tool-bash-persistent/package.json b/packages/pty/tool-bash-persistent/package.json index 34d95c6f78..25f248e047 100644 --- a/packages/pty/tool-bash-persistent/package.json +++ b/packages/pty/tool-bash-persistent/package.json @@ -31,15 +31,15 @@ ], "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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", @@ -56,6 +56,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/pty/tool-pty/package.json b/packages/pty/tool-pty/package.json index 2db01c4416..c60acc3572 100644 --- a/packages/pty/tool-pty/package.json +++ b/packages/pty/tool-pty/package.json @@ -32,18 +32,18 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/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", - "@deepseek-ai/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": { "@deepseek-ai/cordis-plugin-include": "workspace:^", @@ -63,6 +63,6 @@ "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index c68cd8532a..52a2801b7b 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -32,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", - "@deepseek-ai/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:*", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index d90befdd9e..9aaf77e59f 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-sandbox": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index 6d8d9c722b..a59898ea86 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -38,8 +38,8 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { "koffi": "^3.1.0" @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-pwsh-local": "workspace:^", "@deepseek-ai/dsh-sandbox-local": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 9fc9f8a460..057920ce44 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -32,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/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/scaffold/client/package.json b/packages/scaffold/client/package.json index 227e00a1ab..41786be068 100644 --- a/packages/scaffold/client/package.json +++ b/packages/scaffold/client/package.json @@ -31,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/scaffold/create-sdk/package.json b/packages/scaffold/create-sdk/package.json index e61b645303..3f4395eed7 100644 --- a/packages/scaffold/create-sdk/package.json +++ b/packages/scaffold/create-sdk/package.json @@ -39,11 +39,11 @@ "commander": "^15.0.0" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/scaffold/helper/package.json b/packages/scaffold/helper/package.json index 0266daaba1..d79661c0ea 100644 --- a/packages/scaffold/helper/package.json +++ b/packages/scaffold/helper/package.json @@ -38,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", - "@deepseek-ai/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:^", @@ -54,6 +54,6 @@ "@deepseek-ai/dsh-tool-subagent": "workspace:^", "@deepseek-ai/dsh-tool-todo": "workspace:^", "@deepseek-ai/dsh-tool-web": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/scaffold/protocol/package.json b/packages/scaffold/protocol/package.json index feb8784a6d..7fc86b5fc3 100644 --- a/packages/scaffold/protocol/package.json +++ b/packages/scaffold/protocol/package.json @@ -31,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/scaffold/scripts/package.json b/packages/scaffold/scripts/package.json index e24969d78d..20fd286b50 100644 --- a/packages/scaffold/scripts/package.json +++ b/packages/scaffold/scripts/package.json @@ -48,8 +48,8 @@ }, "peerDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^", "tsdown": "^0.22.2", "tsx": "^4.22.4" }, @@ -64,7 +64,7 @@ "devDependencies": { "@deepseek-ai/dsh-app-boot": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "tsdown": "^0.22.2", "tsx": "^4.22.4" } diff --git a/packages/scaffold/server/package.json b/packages/scaffold/server/package.json index 6911f4b117..237ce06ecc 100644 --- a/packages/scaffold/server/package.json +++ b/packages/scaffold/server/package.json @@ -32,18 +32,18 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/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", - "@deepseek-ai/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": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -57,6 +57,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/scaffold/telemetry/package.json b/packages/scaffold/telemetry/package.json index 419d8f9ed1..50c501cd51 100644 --- a/packages/scaffold/telemetry/package.json +++ b/packages/scaffold/telemetry/package.json @@ -35,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/self-modification/tool-cordis/package.json b/packages/self-modification/tool-cordis/package.json index 1fc3fcebb1..ed0f466b62 100644 --- a/packages/self-modification/tool-cordis/package.json +++ b/packages/self-modification/tool-cordis/package.json @@ -32,16 +32,16 @@ ], "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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", + "@deepseek-ai/cordis-plugin-loader": "workspace:^", "@deepseek-ai/cordis-plugin-timer": "workspace:^", "@deepseek-ai/dsh-agent": "workspace:^", "@deepseek-ai/dsh-agent-loop": "workspace:^", @@ -52,6 +52,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index a54c9e0e1b..9a799bf167 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -32,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", - "@deepseek-ai/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": { @@ -44,7 +44,7 @@ } }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -53,6 +53,6 @@ "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-query": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index cdd3d740f5..6f19563c7b 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -32,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", - "@deepseek-ai/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": { @@ -52,6 +52,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 0da248f48a..71afb52687 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -58,6 +58,6 @@ "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-timeout-policy": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 8a5766641f..1151cf1a9c 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -32,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-session-persistence": "^0.0.1", - "@deepseek-ai/dsh-tools": "^0.0.1", - "@deepseek-ai/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": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -52,6 +52,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 6bc752ed0f..38d6cfba97 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -32,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", - "@deepseek-ai/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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json index 8d6f05cbd8..f52fdc6bca 100644 --- a/packages/session/session-persistence-sqlite/package.json +++ b/packages/session/session-persistence-sqlite/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index d556ebff73..8430c7c91a 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -32,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", - "@deepseek-ai/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:^", @@ -44,6 +44,6 @@ "@deepseek-ai/dsh-scope": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index 885c293dc9..cbc40eebe0 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -32,16 +32,16 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/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", - "@deepseek-ai/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:^", @@ -50,6 +50,6 @@ "@deepseek-ai/dsh-session-projection": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", "@deepseek-ai/dsh-storage-domain": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index d17884eadb..4d7a438fae 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -40,13 +40,13 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 569e62e70b..0d2a88bc6a 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -38,16 +38,16 @@ "@opentelemetry/otlp-exporter-base": "^0.220.0", "@opentelemetry/resources": "^2.9.0", "@opentelemetry/sdk-logs": "^0.220.0", - "@deepseek-ai/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", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@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:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -57,6 +57,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-telemetry": "workspace:^", "@deepseek-ai/dsh-user-id": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index d9ac09b6b7..613d1f6ac3 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -32,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-title-all-messages-llm/package.json b/packages/session/session-title-all-messages-llm/package.json index ecd82f5f72..c406342a9f 100644 --- a/packages/session/session-title-all-messages-llm/package.json +++ b/packages/session/session-title-all-messages-llm/package.json @@ -27,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -43,6 +43,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-llm": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-title-first-message-llm/package.json b/packages/session/session-title-first-message-llm/package.json index 88e8ddb1ca..a75f220053 100644 --- a/packages/session/session-title-first-message-llm/package.json +++ b/packages/session/session-title-first-message-llm/package.json @@ -27,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-include": "workspace:^", @@ -46,6 +46,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-session-title-llm": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 7b2c4caacd..062eca9259 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-title": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index 84a3b8caaf..e0239f185b 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -41,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { @@ -60,6 +60,6 @@ "@deepseek-ai/dsh-session-persistence-jsonl": "workspace:^", "@deepseek-ai/dsh-session-persistence-sqlite": "workspace:^", "@deepseek-ai/dsh-session-projection": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/session/user-id/package.json b/packages/session/user-id/package.json index fe733ccfd0..36046b5b58 100644 --- a/packages/session/user-id/package.json +++ b/packages/session/user-id/package.json @@ -32,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index c66e51274d..0f5bca31f3 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -32,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", - "@deepseek-ai/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", - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "yaml": "^2.9.0" }, "devDependencies": { @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-settings": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index da6a1cccc0..568a8fba58 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -32,15 +32,15 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-brand": "^0.0.1", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/cordis": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^" } } diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index 5b23d44f9c..eb55678529 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -32,13 +32,13 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-skill": "^0.0.1", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index 1cec60bf92..386da9386b 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -32,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", - "@deepseek-ai/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", - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "yaml": "^2.4.2" }, "devDependencies": { @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-paths": "workspace:^", "@deepseek-ai/dsh-skill": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index 921adf7632..b546ae9c60 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-scope": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index 25e0f9b2c9..f1315b47cf 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -51,6 +51,6 @@ "@deepseek-ai/dsh-skill": "workspace:^", "@deepseek-ai/dsh-skill-local": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index 7dd63ce56a..43f1bfcfeb 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -32,12 +32,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-spill": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-spill": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-brand": "workspace:^", @@ -45,6 +45,6 @@ "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index f425208b8d..8236e9104a 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -52,6 +52,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-spill": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 9340d28a37..48a21f52c9 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -32,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 5c6f8836f0..9c3acd2ba7 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -32,17 +32,17 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-storage": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "zod": "^4.4.3" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index 73d8ed9e74..86ec3dc5b8 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -32,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-storage": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index 4ad22a7946..b30e80b5e7 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -32,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-storage": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-storage": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index b638d392e0..efe8294de3 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 155e89ced1..62a648e3d5 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -32,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", - "@deepseek-ai/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", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis-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:^", @@ -56,6 +56,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 758d30435c..22876aa0ca 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -32,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", - "@deepseek-ai/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", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -55,6 +55,6 @@ "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index e44de3fa75..d22b5c517f 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis-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:^", @@ -57,6 +57,6 @@ "@deepseek-ai/dsh-subprocess-local": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@openai/codex": "0.147.0", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index 22d66af4bd..d6cc3a3eef 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis-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:^", @@ -55,6 +55,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index c0abf8ab3f..08515ea47b 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis-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:^", @@ -53,6 +53,6 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index 09fe807748..8bf3d7a8ca 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -32,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-llm": "^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/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-system-prompt": "workspace:^", + "@deepseek-ai/dsh-tools": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis-plugin-include": "^1.0.4", - "@deepseek-ai/cordis-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:^", @@ -58,6 +58,6 @@ "@deepseek-ai/dsh-tool-fs": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index c865aa5251..a45bc78143 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis-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:^", @@ -55,6 +55,6 @@ "@deepseek-ai/dsh-subagent-inprocess": "workspace:^", "@deepseek-ai/dsh-tool-bash": "workspace:^", "@deepseek-ai/dsh-tool-subagent": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 070fe2c8cb..4be1d4d947 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -40,22 +40,22 @@ "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-sandbox": "^0.0.1", - "@deepseek-ai/dsh-sandbox-policy": "^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", - "@deepseek-ai/dsh-user-approval": "^0.0.1", - "@deepseek-ai/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": { @@ -101,6 +101,6 @@ "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-approval": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index c89d35c727..0184a5af1a 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -37,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", - "@deepseek-ai/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:^", @@ -57,6 +57,6 @@ "@deepseek-ai/dsh-subagent": "workspace:^", "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index 665230bdbd..78adfef325 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -54,6 +54,6 @@ "@deepseek-ai/dsh-subagent-spawn": "workspace:^", "@deepseek-ai/dsh-tool-subagent-control": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 5ba14c54b7..6930f5dcc2 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis-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:^", @@ -58,6 +58,6 @@ "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tool-tasks": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index a9547a9c48..d307a0742b 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -36,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", - "@deepseek-ai/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" @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-subprocess": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index a1a4e40865..214f5f67a8 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index 929ba31708..642565885f 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -37,13 +37,13 @@ "vitest": "^4.1.8" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-session": "^0.0.1", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/support/agent-loop-testkit/package.json b/packages/support/agent-loop-testkit/package.json index 5ca1382750..0bde847ca9 100644 --- a/packages/support/agent-loop-testkit/package.json +++ b/packages/support/agent-loop-testkit/package.json @@ -32,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", - "@deepseek-ai/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:^", @@ -48,6 +48,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 0f1503b835..122ab3dd9f 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -32,12 +32,12 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/support/llm-mock-server/package.json b/packages/support/llm-mock-server/package.json index 9f5acda6cb..13456d14e7 100644 --- a/packages/support/llm-mock-server/package.json +++ b/packages/support/llm-mock-server/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index d199ea5a4f..326d13bb81 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -32,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index 0de74f6e60..169bf2f548 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -36,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", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index aa4673b811..b92cbfe03f 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -32,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", - "@deepseek-ai/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:^", @@ -47,6 +47,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index 2e59a68679..5bd6a7e3c1 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -32,17 +32,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-session": "^0.0.1", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index f55816cc82..69aed7e859 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -32,17 +32,17 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/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", - "@deepseek-ai/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:^", @@ -54,6 +54,6 @@ "@deepseek-ai/dsh-tasks": "workspace:^", "@deepseek-ai/dsh-tasks-local": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index 3bd9294687..d15d05bb10 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -37,16 +37,16 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@deepseek-ai/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", - "@deepseek-ai/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": { "@deepseek-ai/cordis-plugin-include": "workspace:^", @@ -62,6 +62,6 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-user-interaction": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index 117f18f48b..f17ce63b8a 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -41,14 +41,14 @@ "typescript": "^6.0.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/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:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "zod": "^4.4.3" } } diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 4f33f4bc23..7d1baa743a 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -32,19 +32,19 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-typert-registry": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-typert-registry": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@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": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "zod": "^4.4.3" } } diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 67f2611db1..464415116b 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -57,11 +57,11 @@ "zod": "^4.4.3" }, "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json index ae3fb96a2b..e383ba0eec 100644 --- a/packages/typert/type-meta/package.json +++ b/packages/typert/type-meta/package.json @@ -37,11 +37,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index 5556d4ab96..b514058816 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index cdf9307e73..01f33fefe5 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/environment/package.json b/packages/util/environment/package.json index 7eee1c2b1e..ddc2e40d23 100644 --- a/packages/util/environment/package.json +++ b/packages/util/environment/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index 81c6c8c771..b3145ee41a 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/paths/package.json b/packages/util/paths/package.json index 2405fc5b65..04d692f56d 100644 --- a/packages/util/paths/package.json +++ b/packages/util/paths/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/retention/package.json b/packages/util/retention/package.json index d7eec5489a..7e172bb131 100644 --- a/packages/util/retention/package.json +++ b/packages/util/retention/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.6" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 2404dd13ff..194662100a 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -32,11 +32,11 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index 2eb44ba59c..c5e1ea2b6b 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -32,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", - "@deepseek-ai/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", - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/schemastery": "workspace:^", "turndown": "^7.2.4" }, "devDependencies": { @@ -58,6 +58,6 @@ "@deepseek-ai/dsh-web": "workspace:^", "@deepseek-ai/dsh-web-fetch-local": "workspace:^", "@deepseek-ai/dsh-web-search-exa": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json index 61948fb142..148ecde3e0 100644 --- a/packages/web/web-fetch-local/package.json +++ b/packages/web/web-fetch-local/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-timeout": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index bcca1d616a..f3b29d20a4 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -51,6 +51,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index 15e907649d..da16ca3fce 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index a952c7f83d..1d266f1d13 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-environment": "workspace:^", "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-web": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/web/web/package.json b/packages/web/web/package.json index 6f74c65f2b..d9cf4f5726 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -32,16 +32,16 @@ ], "license": "BSD-3-Clause", "peerDependencies": { - "@deepseek-ai/dsh-invariants": "^0.0.1", - "@deepseek-ai/dsh-llm": "^0.0.1", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/dsh-invariants": "workspace:^", + "@deepseek-ai/dsh-llm": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 184f656206..341508e3ed 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -32,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-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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/cordis-plugin-loader": "workspace:^", @@ -59,6 +59,6 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index 125df12935..d111d2797d 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -32,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -53,6 +53,6 @@ "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", "@deepseek-ai/dsh-workflow-workerthread": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index d085ba235f..53e03ebc2d 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -37,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", - "@deepseek-ai/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": { - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@deepseek-ai/dsh-agent": "workspace:^", @@ -63,7 +63,7 @@ "@deepseek-ai/dsh-system-prompt": "workspace:^", "@deepseek-ai/dsh-tools": "workspace:^", "@deepseek-ai/dsh-workflow": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "tsx": "^4.19.2" } } diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index e2a6eb21d3..1c342bcebe 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -32,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", - "@deepseek-ai/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:^", @@ -45,6 +45,6 @@ "@deepseek-ai/dsh-invariants": "workspace:^", "@deepseek-ai/dsh-llm": "workspace:^", "@deepseek-ai/dsh-session": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 9c0c5127d5..7efc53b6c8 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -37,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", - "@deepseek-ai/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" @@ -55,6 +55,6 @@ "@deepseek-ai/dsh-session": "workspace:^", "@deepseek-ai/dsh-session-persistence": "workspace:^", "@deepseek-ai/dsh-storage": "workspace:^", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9e74ebfd80..16602cc946 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -127,7 +127,7 @@ importers: apps/cli: dependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../vendor/cordis '@deepseek-ai/cordis-plugin-hmr': specifier: workspace:* @@ -752,7 +752,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -786,7 +786,7 @@ importers: version: link:../../typert/type-meta devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -811,7 +811,7 @@ importers: version: link:../../typert/type-meta devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -835,7 +835,7 @@ importers: packages/attachment/attachment: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -854,7 +854,7 @@ importers: version: 0.35.3(@types/node@22.20.0) devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-attachment': specifier: workspace:^ @@ -869,7 +869,7 @@ importers: packages/bash/bash: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -888,7 +888,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -919,7 +919,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -940,7 +940,7 @@ importers: packages/bash/bash-sandbox: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -974,7 +974,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -995,7 +995,7 @@ importers: packages/bash/pwsh-sandbox: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -1026,7 +1026,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -1093,7 +1093,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -1151,7 +1151,7 @@ importers: version: 4.2.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-group': specifier: workspace:^ @@ -1187,7 +1187,7 @@ importers: packages/boot/cmdline: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -1443,7 +1443,7 @@ importers: version: link:../../context/workspace-context devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -1465,7 +1465,7 @@ importers: version: 15.0.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -1625,7 +1625,7 @@ importers: version: 15.0.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -1668,7 +1668,7 @@ importers: version: 8.21.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-host-webserver': specifier: workspace:^ @@ -1687,7 +1687,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -1705,7 +1705,7 @@ importers: packages/client/locale: dependencies: '@deepseek-ai/dsh-client-connection': - specifier: ^0.0.1 + specifier: workspace:^ version: link:../connection '@deepseek-ai/dsh-settings': specifier: workspace:^ @@ -1715,7 +1715,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -1739,7 +1739,7 @@ importers: packages/client/modules: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -1806,7 +1806,7 @@ importers: version: 4.4.7(@types/react@18.3.31)(immer@10.2.0)(react@18.3.1) devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -1834,7 +1834,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -1853,7 +1853,7 @@ importers: 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: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -1886,7 +1886,7 @@ importers: packages/client/ui-agent-preset: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -1932,7 +1932,7 @@ importers: version: 2.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -1981,7 +1981,7 @@ importers: version: 2.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2063,7 +2063,7 @@ importers: version: 18.3.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ @@ -2090,7 +2090,7 @@ importers: packages/client/ui-goal: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-api-remotes': specifier: workspace:^ @@ -2135,7 +2135,7 @@ importers: packages/client/ui-layout: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ @@ -2162,7 +2162,7 @@ importers: packages/client/ui-model: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -2204,7 +2204,7 @@ importers: packages/client/ui-models: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -2246,7 +2246,7 @@ importers: packages/client/ui-permission: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -2291,7 +2291,7 @@ importers: packages/client/ui-plan: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -2394,7 +2394,7 @@ importers: version: 4.3.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -2431,7 +2431,7 @@ importers: version: 18.3.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -2462,7 +2462,7 @@ importers: version: 2.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ @@ -2505,7 +2505,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -2548,7 +2548,7 @@ importers: version: 2.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ @@ -2581,7 +2581,7 @@ importers: packages/client/ui-skill: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -2630,7 +2630,7 @@ importers: version: 2.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ @@ -2660,7 +2660,7 @@ importers: packages/client/ui-slots: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -2676,7 +2676,7 @@ importers: version: 18.3.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ @@ -2715,7 +2715,7 @@ importers: 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:^ @@ -2728,7 +2728,7 @@ importers: version: 2.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ @@ -2762,7 +2762,7 @@ importers: version: 2.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-connection': specifier: workspace:^ @@ -2814,7 +2814,7 @@ importers: version: 9.0.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -2851,7 +2851,7 @@ importers: version: 2.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ @@ -2912,7 +2912,7 @@ importers: version: 18.3.1(react@18.3.1) devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -2949,7 +2949,7 @@ importers: version: 1.2.0(react@18.3.1) devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -2961,7 +2961,7 @@ importers: packages/code-runtime/code-runtime: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -2974,7 +2974,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-code-runtime': specifier: workspace:^ @@ -2992,7 +2992,7 @@ importers: packages/compact/command-compact: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -3022,7 +3022,7 @@ importers: packages/compact/compact: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -3047,7 +3047,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -3099,7 +3099,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -3130,7 +3130,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3161,7 +3161,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3198,7 +3198,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3226,7 +3226,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.6 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -3271,7 +3271,7 @@ importers: packages/core/agent: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -3302,7 +3302,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3324,7 +3324,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3361,7 +3361,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3388,7 +3388,7 @@ importers: packages/core/scope: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -3397,7 +3397,7 @@ importers: packages/core/session: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -3425,7 +3425,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -3444,7 +3444,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3474,7 +3474,7 @@ importers: packages/credentials/credentials: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -3496,7 +3496,7 @@ importers: version: 2.9.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ @@ -3524,7 +3524,7 @@ importers: version: 2.29.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -3539,7 +3539,7 @@ importers: packages/e2b/fs-e2b: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-e2b': specifier: workspace:^ @@ -3558,7 +3558,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-e2b': specifier: workspace:^ @@ -3580,7 +3580,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -3632,7 +3632,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ @@ -3744,7 +3744,7 @@ importers: version: link:../../boot/app-boot devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -3753,7 +3753,7 @@ importers: packages/feedback/command-feedback: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -3783,7 +3783,7 @@ importers: packages/fs/fs: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -3808,7 +3808,7 @@ importers: version: 3.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-fs': specifier: workspace:^ @@ -3823,7 +3823,7 @@ importers: packages/fs/fs-policy: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-fs': specifier: workspace:^ @@ -3838,7 +3838,7 @@ importers: packages/fs/fs-sandbox: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-fs': specifier: workspace:^ @@ -3866,7 +3866,7 @@ importers: version: 9.0.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3924,7 +3924,7 @@ importers: version: 1.18.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.6 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -3967,7 +3967,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4009,7 +4009,7 @@ importers: packages/goal/command-goal: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -4043,7 +4043,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4076,7 +4076,7 @@ importers: packages/goal/goal-session: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4113,7 +4113,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -4147,7 +4147,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4174,7 +4174,7 @@ importers: packages/guard/timeout-policy: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4192,7 +4192,7 @@ importers: packages/hooks/hook-protocol: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -4211,7 +4211,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4263,7 +4263,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4387,7 +4387,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent-presets': specifier: workspace:^ @@ -4411,7 +4411,7 @@ importers: packages/host/directory-picker: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4420,7 +4420,7 @@ importers: packages/host/directory-picker-auto: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -4457,7 +4457,7 @@ importers: version: 2.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-locale': specifier: workspace:^ @@ -4500,7 +4500,7 @@ importers: version: 3.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-client-runtime': specifier: workspace:^ @@ -4531,7 +4531,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -4550,7 +4550,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -4559,7 +4559,7 @@ importers: packages/interaction/commands: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4587,7 +4587,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-bash': specifier: workspace:^ @@ -4620,7 +4620,7 @@ importers: packages/interaction/tool-ask-user: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4648,7 +4648,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4675,7 +4675,7 @@ importers: packages/interaction/user-interaction: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4694,7 +4694,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-attachment': specifier: workspace:^ @@ -4719,7 +4719,7 @@ importers: version: 3.1.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-credentials': specifier: workspace:^ @@ -4750,7 +4750,7 @@ importers: version: 0.82.1(@modelcontextprotocol/sdk@1.29.0(zod@4.4.3))(ws@8.21.0)(zod@4.4.3) devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-attachment': specifier: workspace:^ @@ -4784,7 +4784,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -4845,7 +4845,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-compact': specifier: workspace:^ @@ -4866,7 +4866,7 @@ importers: packages/lsp/lsp: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -4885,7 +4885,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -4928,7 +4928,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -4980,7 +4980,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5008,7 +5008,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5054,7 +5054,7 @@ importers: version: 4.2.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -5106,7 +5106,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5121,7 +5121,7 @@ importers: packages/pty/pty: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5143,7 +5143,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5177,7 +5177,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -5229,7 +5229,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -5286,7 +5286,7 @@ importers: packages/sandbox/sandbox: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5311,7 +5311,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5333,7 +5333,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5358,7 +5358,7 @@ importers: version: 3.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5373,7 +5373,7 @@ importers: packages/scaffold/client: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5398,7 +5398,7 @@ importers: version: 15.0.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5423,7 +5423,7 @@ importers: version: 2.9.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -5459,7 +5459,7 @@ importers: packages/scaffold/protocol: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5490,7 +5490,7 @@ importers: version: 0.1.4 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-app-boot': specifier: workspace:^ @@ -5512,7 +5512,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -5555,7 +5555,7 @@ importers: version: 2.9.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -5574,10 +5574,10 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': - specifier: ^1.0.0-rc.5 + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/cordis-plugin-timer': specifier: workspace:^ @@ -5613,7 +5613,7 @@ importers: packages/session-query/session-query: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -5641,7 +5641,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -5669,7 +5669,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5714,7 +5714,7 @@ importers: packages/session/session-checkpoint-policy: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -5753,7 +5753,7 @@ importers: packages/session/session-persistence: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -5781,7 +5781,7 @@ importers: version: 3.1.1 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5800,7 +5800,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5819,7 +5819,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5838,7 +5838,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5862,7 +5862,7 @@ importers: packages/session/session-telemetry: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -5899,7 +5899,7 @@ importers: version: 0.220.0(@opentelemetry/api@1.9.1) devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -5933,7 +5933,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -5964,7 +5964,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -5989,7 +5989,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -6023,7 +6023,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6044,7 +6044,7 @@ importers: packages/session/user-id: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -6063,7 +6063,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -6085,7 +6085,7 @@ importers: version: 2.9.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-atomic-write': specifier: workspace:^ @@ -6107,7 +6107,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6122,7 +6122,7 @@ importers: packages/skill/skill-badge: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6144,7 +6144,7 @@ importers: version: 2.9.0 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-fs': specifier: workspace:^ @@ -6166,7 +6166,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6196,7 +6196,7 @@ importers: packages/spill/spill: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.6 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -6218,7 +6218,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.6 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -6243,7 +6243,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.6 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6273,7 +6273,7 @@ importers: packages/storage/storage: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6289,7 +6289,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6305,7 +6305,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6321,7 +6321,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6337,7 +6337,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6401,10 +6401,10 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': - specifier: ^1.0.0-rc.5 + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6447,7 +6447,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6484,10 +6484,10 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': - specifier: ^1.0.0-rc.5 + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6530,10 +6530,10 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': - specifier: ^1.0.0-rc.5 + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6570,10 +6570,10 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': - specifier: ^1.0.0-rc.5 + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6606,13 +6606,13 @@ importers: packages/subagent/subagent-inprocess: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': - specifier: ^1.0.4 + specifier: workspace:^ version: link:../../../vendor/include '@deepseek-ai/cordis-plugin-loader': - specifier: ^1.0.0-rc.5 + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6664,10 +6664,10 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': - specifier: ^1.0.0-rc.5 + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6716,10 +6716,10 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': - specifier: ^1.0.0-rc.5 + specifier: workspace:^ version: link:../../../vendor/loader '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6764,7 +6764,7 @@ importers: packages/subagent/tool-subagent-control: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6810,7 +6810,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6852,7 +6852,7 @@ importers: packages/subprocess/subprocess: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6865,7 +6865,7 @@ importers: version: 1.1.0(patch_hash=7a0c04f1f49d798a9ffe2f7f414c01064a44ca2489772d0c3e1235ab336755e6) devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6890,7 +6890,7 @@ importers: 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: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6902,7 +6902,7 @@ importers: packages/support/agent-loop-testkit: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6933,13 +6933,13 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis packages/support/llm-mock-server: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -6948,7 +6948,7 @@ importers: packages/support/llm-replay: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-compact': specifier: workspace:^ @@ -6973,7 +6973,7 @@ importers: version: 4.22.4 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.6 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -6991,7 +6991,7 @@ importers: packages/tasks/tasks: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.6 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -7009,7 +7009,7 @@ importers: packages/tasks/tasks-local: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -7040,7 +7040,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.6 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -7080,7 +7080,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-include': specifier: workspace:^ @@ -7132,7 +7132,7 @@ importers: version: 6.0.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -7154,7 +7154,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -7179,7 +7179,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -7188,7 +7188,7 @@ importers: packages/typert/type-meta: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -7197,7 +7197,7 @@ importers: packages/util/atomic-write: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -7206,7 +7206,7 @@ importers: packages/util/brand: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -7215,7 +7215,7 @@ importers: packages/util/environment: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -7224,7 +7224,7 @@ importers: packages/util/native-command: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -7233,7 +7233,7 @@ importers: packages/util/paths: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.6 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -7242,7 +7242,7 @@ importers: packages/util/retention: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.6 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -7251,7 +7251,7 @@ importers: packages/util/timeout: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -7270,7 +7270,7 @@ importers: version: 7.2.4 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -7319,7 +7319,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -7335,7 +7335,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-invariants': specifier: workspace:^ @@ -7354,7 +7354,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -7385,7 +7385,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-environment': specifier: workspace:^ @@ -7404,7 +7404,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-environment': specifier: workspace:^ @@ -7423,7 +7423,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/cordis-plugin-loader': specifier: workspace:^ @@ -7475,7 +7475,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -7508,7 +7508,7 @@ importers: packages/workflow/workflow: devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -7533,7 +7533,7 @@ importers: version: link:../../../vendor/schemastery devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-agent': specifier: workspace:^ @@ -7582,7 +7582,7 @@ importers: version: 4.4.3 devDependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../../../vendor/cordis '@deepseek-ai/dsh-brand': specifier: workspace:^ @@ -7927,10 +7927,10 @@ importers: vendor/cordis: dependencies: '@deepseek-ai/cordis-plugin-include': - specifier: ^1.0.4 + specifier: workspace:^ version: link:../include '@deepseek-ai/cordis-plugin-loader': - specifier: ^1.0.0-rc.5 + specifier: workspace:^ version: link:../loader '@deepseek-ai/cosmokit': specifier: link:../cosmokit @@ -7944,10 +7944,10 @@ importers: vendor/group: dependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../cordis '@deepseek-ai/cordis-plugin-loader': - specifier: ^1.0.0-rc.5 + specifier: workspace:^ version: link:../loader vendor/hmr: @@ -7956,10 +7956,10 @@ importers: specifier: ^7.29.0 version: 7.29.7 '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../cordis '@deepseek-ai/cordis-plugin-timer': - specifier: ^1.1.2 + specifier: workspace:^ version: link:../timer '@deepseek-ai/cosmokit': specifier: link:../cosmokit @@ -7987,10 +7987,10 @@ importers: vendor/include: dependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../cordis '@deepseek-ai/cordis-plugin-loader': - specifier: ^1.0.0-rc.5 + specifier: workspace:^ version: link:../loader '@deepseek-ai/cosmokit': specifier: link:../cosmokit @@ -8002,7 +8002,7 @@ importers: vendor/loader: dependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../cordis '@deepseek-ai/cosmokit': specifier: link:../cosmokit @@ -8014,7 +8014,7 @@ importers: vendor/logger-console: dependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../cordis '@deepseek-ai/cosmokit': specifier: link:../cosmokit @@ -8038,7 +8038,7 @@ importers: vendor/timer: dependencies: '@deepseek-ai/cordis': - specifier: ^4.0.0-rc.7 + specifier: workspace:^ version: link:../cordis '@deepseek-ai/cosmokit': specifier: link:../cosmokit diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index cdada48fc5..30afce1e9f 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -80,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. */ @@ -380,9 +382,38 @@ function checkRepositoryVersion(): string[] { return ['package.json: version must be stable X.Y.Z'] } +/** 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/vendor/cordis/package.json b/vendor/cordis/package.json index 3428ccbdbc..576ea209a7 100644 --- a/vendor/cordis/package.json +++ b/vendor/cordis/package.json @@ -32,8 +32,8 @@ "author": "Shigma ", "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis-plugin-include": "^1.0.4", - "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5" + "@deepseek-ai/cordis-plugin-include": "workspace:^", + "@deepseek-ai/cordis-plugin-loader": "workspace:^" }, "peerDependenciesMeta": { "@deepseek-ai/cordis-plugin-include": { @@ -45,6 +45,6 @@ }, "dependencies": { "@standard-schema/spec": "^1.1.0", - "@deepseek-ai/cosmokit": "^1.8.1" + "@deepseek-ai/cosmokit": "workspace:^" } } diff --git a/vendor/group/package.json b/vendor/group/package.json index c9f242a7d3..b5e07fdb66 100644 --- a/vendor/group/package.json +++ b/vendor/group/package.json @@ -30,7 +30,7 @@ "author": "Shigma ", "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" } } diff --git a/vendor/hmr/package.json b/vendor/hmr/package.json index 019c027584..68617b430e 100644 --- a/vendor/hmr/package.json +++ b/vendor/hmr/package.json @@ -41,15 +41,15 @@ } }, "peerDependencies": { - "@deepseek-ai/cordis-plugin-timer": "^1.1.2", - "@deepseek-ai/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", - "@deepseek-ai/cosmokit": "^1.8.1", + "@deepseek-ai/cosmokit": "workspace:^", "picomatch": "^4.0.3", - "@deepseek-ai/schemastery": "^3.18.0" + "@deepseek-ai/schemastery": "workspace:^" }, "devDependencies": { "@types/babel__code-frame": "^7.27.0", diff --git a/vendor/include/package.json b/vendor/include/package.json index 7fb6c3f36b..de1509c92b 100644 --- a/vendor/include/package.json +++ b/vendor/include/package.json @@ -30,11 +30,11 @@ "author": "Shigma ", "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis-plugin-loader": "^1.0.0-rc.5", - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis-plugin-loader": "workspace:^", + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/cosmokit": "^1.8.1", + "@deepseek-ai/cosmokit": "workspace:^", "js-yaml": "^4.1.0" } } diff --git a/vendor/loader/package.json b/vendor/loader/package.json index aab8c19bf5..62e88b2594 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -30,7 +30,7 @@ "author": "Shigma ", "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "^4.0.0-rc.7", + "@deepseek-ai/cordis": "workspace:^", "node-addon-require-builtin": "^0.1.4" }, "peerDependenciesMeta": { @@ -39,6 +39,6 @@ } }, "dependencies": { - "@deepseek-ai/cosmokit": "^1.8.1" + "@deepseek-ai/cosmokit": "workspace:^" } } diff --git a/vendor/logger-console/package.json b/vendor/logger-console/package.json index aeeb81fb50..29b4b423ab 100644 --- a/vendor/logger-console/package.json +++ b/vendor/logger-console/package.json @@ -32,11 +32,11 @@ "author": "Shigma ", "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/cosmokit": "^1.8.1", - "@deepseek-ai/schemastery": "^3.18.0", + "@deepseek-ai/cosmokit": "workspace:^", + "@deepseek-ai/schemastery": "workspace:^", "supports-color": "^9.4.0" } } diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index e31c6e6251..26cfd9b937 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -34,6 +34,6 @@ "license": "MIT", "dependencies": { "@standard-schema/spec": "^1.1.0", - "@deepseek-ai/cosmokit": "^1.8.1" + "@deepseek-ai/cosmokit": "workspace:^" } } diff --git a/vendor/timer/package.json b/vendor/timer/package.json index 32f564aa9d..a8138fae67 100644 --- a/vendor/timer/package.json +++ b/vendor/timer/package.json @@ -30,9 +30,9 @@ "author": "Shigma ", "license": "MIT", "peerDependencies": { - "@deepseek-ai/cordis": "^4.0.0-rc.7" + "@deepseek-ai/cordis": "workspace:^" }, "dependencies": { - "@deepseek-ai/cosmokit": "^1.8.1" + "@deepseek-ai/cosmokit": "workspace:^" } } From 27c9ca12a2d3079e51fc85dff048ccec1828186f Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:26:26 +0800 Subject: [PATCH 043/113] feat(release): drive the installed entry from the packed tarballs MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A throwaway consumer outside the repository declares every member as a file: dependency, installs, and runs the installed executable with plain Node, asserting the version it reports. That is the check a workspace link or a stale lib/ in the checkout cannot pass for: it reads only what files selected. The family declares its executable, so the vendored family — libraries a consumer imports, with no executable — states that it has none instead of carrying a probe that would prove nothing. Both pack workflows run it after packing, still without credentials. --- .github/workflows/release-vendor.yml | 3 + .github/workflows/release.yml | 3 + package.json | 1 + scripts/release/families.ts | 19 ++++ scripts/release/verify-packed-install.ts | 105 +++++++++++++++++++++++ 5 files changed, 131 insertions(+) create mode 100644 scripts/release/verify-packed-install.ts diff --git a/.github/workflows/release-vendor.yml b/.github/workflows/release-vendor.yml index b83547b560..dc778f3d21 100644 --- a/.github/workflows/release-vendor.yml +++ b/.github/workflows/release-vendor.yml @@ -80,6 +80,9 @@ jobs: - 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 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8b4005f42f..affdd3c900 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,6 +79,9 @@ jobs: - name: Pack release tarballs run: pnpm run release:pack --family dsh --out dist/npm + - name: Verify packed install + run: pnpm run release:verify-packed-install --family dsh --from dist/npm + - uses: actions/upload-artifact@v4 with: name: dsh-npm-tarballs diff --git a/package.json b/package.json index 32138d03e8..0d17549909 100644 --- a/package.json +++ b/package.json @@ -123,6 +123,7 @@ "publish:npm-baseline": "tsx scripts/publish-npm-baseline.ts", "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": "node --import tsx/esm apps/cli/src/bin.ts", "demo:headless": "node --import tsx/esm apps/cli/src/bin.ts --profile headless", diff --git a/scripts/release/families.ts b/scripts/release/families.ts index b02c21cb1e..78f5de1951 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -57,6 +57,14 @@ function requireString(manifest: Record, field: string, context 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. */ @@ -167,6 +175,12 @@ export abstract class ReleaseFamily { * @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. */ @@ -206,6 +220,8 @@ class DshFamily extends ReleaseFamily { 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. */ @@ -250,6 +266,9 @@ class VendorFamily extends ReleaseFamily { 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. */ diff --git a/scripts/release/verify-packed-install.ts b/scripts/release/verify-packed-install.ts new file mode 100644 index 0000000000..d50e876f06 --- /dev/null +++ b/scripts/release/verify-packed-install.ts @@ -0,0 +1,105 @@ +/** + * Install a packed release family into a throwaway consumer outside the + * repository and drive its installed executable with plain Node. + * + * Everything the packed tarballs need comes from the tarballs themselves: the + * consumer declares every member as a `file:` dependency, so the only registry + * traffic is for external dependencies. 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 + * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). + */ + +import { spawnSync } from 'node:child_process' +import { mkdtempSync, 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, tarballName, type ReleaseMember } from './families.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 +} + +/** + * Run a command in the consumer and fail the process on a non-zero exit. + * @param command - executable name. + * @param args - command arguments. + * @param cwd - working directory. + * @param env - child environment. + * @returns The captured stdout, trimmed. + */ +function run(command: string, args: readonly string[], cwd: string, env: NodeJS.ProcessEnv): string { + const result = spawnSync(command, [...args], { cwd, env, encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + 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() +} + +/** Install the family named by `--family` from `--from` and drive its entry. */ +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: verify-packed-install.ts --family --from ') + } + + const family = releaseFamily(values.family) + const entry = family.installedEntry + const root = process.cwd() + const packed = resolve(root, values.from) + const members: ReleaseMember[] = family.members(root) + + if (entry === undefined) { + console.log(`release verify-packed-install: family ${family.id} publishes no executable, nothing to drive`) + return + } + + const consumerRoot = mkdtempSync(join(tmpdir(), `dsh-packed-${family.id}-`)) + try { + const dependencies = Object.fromEntries(members.map(member => + [member.name, pathToFileURL(join(packed, tarballName(member))).href])) + writeFileSync(join(consumerRoot, 'package.json'), `${JSON.stringify({ + name: `dsh-packed-install-${family.id}`, + version: '0.0.0', + private: true, + dependencies, + }, null, 2)}\n`) + + const environment = consumerEnvironment(consumerRoot) + console.log(`release verify-packed-install: installing ${String(members.length)} tarball(s) into ${consumerRoot}`) + run('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false'], consumerRoot, environment) + + const bin = join(consumerRoot, 'node_modules', ...entry.packageName.split('/'), entry.binPath) + const version = run(process.execPath, [bin, '--version'], consumerRoot, environment) + const expected = members.find(member => member.name === entry.packageName)?.version + if (version !== expected) { + throw new Error(`installed ${entry.packageName} --version reported ${JSON.stringify(version)}, expected ${String(expected)}`) + } + console.log(`release verify-packed-install: installed ${entry.packageName} reports ${version}`) + } finally { + rmSync(consumerRoot, { recursive: true, force: true }) + } +} + +main() From 580d85d2a8a4867a82c6ead01dc9e123f8bb943b Mon Sep 17 00:00:00 2001 From: Tianyi Cui <53024+tianyicui@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:28:00 +0800 Subject: [PATCH 044/113] fix(ci): await token stats before aria snapshot --- apps/web/tests/message-actions.e2e.ts | 1 + 1 file changed, 1 insertion(+) 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() From a943e67798d7cd8c7d72c788e9184ab75174d605 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:36:39 +0800 Subject: [PATCH 045/113] feat(release): bump and commit a release family in one command release:dsh takes major, minor, patch, or an explicit version and writes one version across the family; release:vendor takes none and increments each package's own patch, but only for packages whose published payload changed since their vendor--v* tag. That tag is the record of the commit a package last published from, so the change judgement needs no state file, and the diff is filtered through the manifest's files rules - editing a vendored comment does not trigger a release. Both refresh the lockfile, commit, and print the tag to create after the commit merges. --dry-run reports the plan and writes nothing. Incrementing the release numbers is also what drops an upstream prerelease segment: cordis 4.0.0-rc.7 publishes as 4.0.1, because a prerelease version would not satisfy a consumer's plain range. --- package.json | 2 + scripts/release/bump.ts | 199 ++++++++++++++++++++++++++++++++++++++++ 2 files changed, 201 insertions(+) create mode 100644 scripts/release/bump.ts diff --git a/package.json b/package.json index 0d17549909..277bd7fe85 100644 --- a/package.json +++ b/package.json @@ -121,6 +121,8 @@ "doc-sync": "tsx scripts/run-gates.ts doc-sync", "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", + "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", diff --git a/scripts/release/bump.ts b/scripts/release/bump.ts new file mode 100644 index 0000000000..83e1b3a2da --- /dev/null +++ b/scripts/release/bump.ts @@ -0,0 +1,199 @@ +/** + * 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/proposed/process/2026-08-10-npm-release-sequences.md)). + * + * The dsh family shares one version: `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 { spawnSync } from 'node:child_process' +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' + +/** Files npm publishes whether or not `files` lists them. */ +const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as const + +/** Release types the dsh family accepts besides an explicit version. */ +const RELEASE_TYPES = ['major', 'minor', 'patch'] as const + +/** + * Run a command and fail the process on a non-zero exit. + * @param command - executable name. + * @param args - command arguments. + * @returns The captured stdout, trimmed. + */ +function run(command: string, args: readonly string[]): string { + const result = spawnSync(command, [...args], { encoding: 'utf8' }) + if (result.error !== undefined) throw result.error + 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() +} + +/** + * 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])] +} + +/** + * 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: its release numbers with the + * patch incremented, which also drops an upstream prerelease segment. + * @param current - the package's current version. + * @returns The target version. + */ +function nextVendorVersion(current: string): string { + const [major, minor, patch] = releaseNumbers(current) + return `${String(major)}.${String(minor)}.${String(patch + 1)}` +} + +/** + * 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` (or npm's always-published set) selects it. + */ +function reachesPayload(member: ReleaseMember, path: string): boolean { + const relative = path.slice(member.directory.length + 1) + const files = member.manifest.files + const patterns = [ + ...ALWAYS_PUBLISHED, + ...Array.isArray(files) ? files.filter((entry): entry is string => typeof entry === 'string') : [], + ] + return patterns.some(pattern => + matchesGlob(relative, pattern) || matchesGlob(relative, `${pattern}/**`) || relative === pattern) +} + +/** + * The newest tag a member published from, or undefined when it never published. + * @param family - the member's family. + * @param member - the member. + * @returns The tag name. + */ +function lastPublishedTag(family: ReleaseFamily, member: ReleaseMember): string | undefined { + const prefix = family.tagFor(member).replace(/-v[^-]*$/, '-v') + const tags = run('git', ['tag', '--list', `${prefix}*`, '--sort=-v:refname']).split('\n').filter(line => line !== '') + return tags[0] +} + +/** + * Whether a member's published payload changed since it last published. + * @param family - the member's family. + * @param member - the member. + * @returns True when the member needs a new version. + */ +function changedSincePublication(family: ReleaseFamily, member: ReleaseMember): boolean { + const tag = lastPublishedTag(family, member) + if (tag === undefined) return true + const changed = run('git', ['diff', '--name-only', `${tag}..HEAD`, '--', member.directory]) + .split('\n').filter(line => line !== '') + return changed.some(path => reachesPayload(member, path)) +} + +/** + * Write a version into a member's manifest, preserving formatting and key order. + * @param root - repository root. + * @param member - the member to rewrite. + * @param version - the target version. + */ +function writeVersion(root: string, member: ReleaseMember, version: string): void { + const path = join(root, member.directory, 'package.json') + const text = readFileSync(path, 'utf8') + const line = `"version": "${member.version}"` + if (!text.includes(line)) throw new Error(`${member.directory}: cannot locate ${line}`) + writeFileSync(path, text.replace(line, `"version": "${version}"`)) +} + +/** Bump the family named by `--family` and commit; `--dry-run` only reports the plan. */ +function main(): void { + const { values, positionals } = parseArgs({ + options: { family: { 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) + + const planned: { member: ReleaseMember; version: string }[] = [] + let sharedVersion: string | undefined + if (family.id === 'dsh') { + const request = positionals[0] + if (request === undefined) throw new Error('usage: release:dsh ') + const [first] = members + if (first === undefined) throw new Error(`release family ${family.id} has no members`) + sharedVersion = nextSharedVersion(first.version, request) + for (const member of members) planned.push({ member, version: sharedVersion }) + } else { + if (positionals.length > 0) throw new Error('release:vendor takes no version: each package increments its own patch') + for (const member of members) { + if (!changedSincePublication(family, member)) continue + planned.push({ member, version: nextVendorVersion(member.version) }) + } + } + + 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 { member, version } of planned) writeVersion(root, member, version) + run('pnpm', ['install', '--lockfile-only']) + } + + const summary = sharedVersion + ?? planned.map(entry => `${entry.member.name.replace('@deepseek-ai/', '')} ${entry.version}`).join(', ') + console.log(`release bump: family ${family.id} -> ${summary}`) + for (const { member, version } of planned) console.log(` ${member.directory}: ${member.version} -> ${version}`) + + if (dryRun) { + console.log('release bump: dry run, nothing written') + return + } + run('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => join(entry.member.directory, 'package.json'))]) + run('git', ['commit', '-m', `release(${family.id}): ${summary}`]) + // The dsh family tags once for its shared version; vendor tags each package. + const tags = [...new Set(planned.map(entry => family.tagFor({ ...entry.member, version: entry.version })))] + console.log('release bump: committed. After this merges to master, tag it:') + for (const tag of tags) console.log(` git tag ${tag} && git push origin ${tag}`) +} + +main() From bb489d13251f0aeb5b934e2a39dff68f5364eb9d Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:46:04 +0800 Subject: [PATCH 046/113] fix(release): align the package-invariant and public-link gates The invariant companion rule pinned @deepseek-ai/dsh-invariants to a ^0.0.1 peerDependency, which the workspace protocol replaces; it now requires workspace:^ like every other workspace-internal reference. The release note stated the provenance risk by naming the internal repository, which the public-link gate rejects in tracked files. It now states the same constraint without naming it. --- .../process/2026-08-10-npm-release-sequences.i18n.yaml | 4 ++-- .../proposed/process/2026-08-10-npm-release-sequences.md | 2 +- .../proposed/process/2026-08-10-npm-release-sequences.zh.md | 2 +- scripts/package-invariants.ts | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml index df41fb5816..76798fc636 100644 --- a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml +++ b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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/process/2026-08-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: 700d922495c539145dd470fbff84e3e211f6ba4e -2026-08-10-npm-release-sequences.zh.md: 59761eacb1c65d4a38e721a319924bf58c5b423e +2026-08-10-npm-release-sequences.md: bfe4ccaeddee3713445a3ffb77d575e4af207ae2 +2026-08-10-npm-release-sequences.zh.md: 3df42748429ac62e8131bfe22e4ebaafb4943b32 diff --git a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md index 700d922495..bfe4ccaedd 100644 --- a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md @@ -180,6 +180,6 @@ This Agent Note replaces the version scheme and the release-set boundary in [art **The visibility cost of private packages.** After `--access restricted`, every consumer — CI, sandbox e2e, and outside users — needs scope credentials to install. The three native packages move to `restricted` as well; they have never been published, so no existing anonymous install path is cut off. -**The `repository` organization differs from the one running the workflow.** The release set names `github.com/deepseek-ai/deepseek-harness` while the workflow runs in `deepseek-harness/deepseek-harness`. Token-based publication is unaffected; npm provenance (OIDC) requires the two to agree, so adopting it means either changing `repository` or publishing from the public repository. +**The `repository` field names a different organization than the one running the workflow.** The release set points consumers at `github.com/deepseek-ai/deepseek-harness`, which is not where these workflows run. 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. **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. Driving the complete path with `0.0.1-rc.1` first is the only mitigation, which is why numbered versions wait for that to pass. diff --git a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md index 59761eacb1..3df4274842 100644 --- a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md +++ b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md @@ -183,6 +183,6 @@ publish 不读 tag、不读任何清单,对发布集里每个包比较 manifes **私有包的可见性代价。** `--access restricted` 之后,任何消费方(含 CI、沙箱 e2e、外部使用者)都必须持有 scope 凭据才能安装。native 三包一并转 `restricted`;它们尚未发布过,因此没有既有的匿名安装路径被切断。 -**`repository` 指向的组织与运行 workflow 的组织不一致。** 发布集写的是 `github.com/deepseek-ai/deepseek-harness`,而 workflow 跑在 `deepseek-harness/deepseek-harness`。用 token 发布不受影响;一旦改用 npm provenance(OIDC),npm 会要求二者一致,届时要么改 `repository`,要么从公开仓库发布。 +**`repository` 指向的组织与运行 workflow 的组织不一致。** 发布集让消费方指向 `github.com/deepseek-ai/deepseek-harness`,而这些 workflow 并不跑在那里。用 token 发布不受影响;一旦改用 npm provenance(OIDC),npm 会要求二者一致,届时要么把 `repository` 改指过去,要么从它指向的组织发布。 **首发一次性放大。** vendor 首发九包、dsh 首发全闭包,任何 payload 缺陷都会在同一次发布里暴露。用 `0.0.1-rc.1` 先跑一遍完整链路是唯一的缓解手段,正式版本号留给验证通过之后。 diff --git a/scripts/package-invariants.ts b/scripts/package-invariants.ts index eeae171a0b..98ade09aa6 100644 --- a/scripts/package-invariants.ts +++ b/scripts/package-invariants.ts @@ -96,11 +96,11 @@ function checkManifest( addViolation(violations, owner.manifestPath, 'files must publish lib/invariant.js') } if (owner.packageName === '@deepseek-ai/dsh-invariants') return - if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== '^0.0.1') { + if (manifest.peerDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') { addViolation( violations, owner.manifestPath, - '@deepseek-ai/dsh-invariants must be a ^0.0.1 peerDependency', + '@deepseek-ai/dsh-invariants must be a workspace:^ peerDependency', ) } if (manifest.devDependencies?.['@deepseek-ai/dsh-invariants'] !== 'workspace:^') { From 787443b13f55d3daa3b8e1a57dac29214759ffbc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:51:02 +0800 Subject: [PATCH 047/113] fix(release): verify the packed install against the framework it peers on The harness packages declare the vendored framework as a peer, so installing only the dsh tarballs left npm resolving @deepseek-ai/cordis from a private registry the credential-free pack job cannot reach. The verification now takes several pack directories and installs every tarball in them, and the dsh workflow packs the vendored family for that purpose while still publishing only its own set. The verification also reads what each tarball declares instead of what the checkout says, which is what let the process and tarball helpers become one home each - the three copies of a spawn wrapper were a duplication finding. --- .github/workflows/release.yml | 8 ++- scripts/release/bump.ts | 27 ++------ scripts/release/pack.ts | 29 +-------- scripts/release/process.ts | 65 +++++++++++++++++++ scripts/release/publish.ts | 43 ++----------- scripts/release/tarball.ts | 53 ++++++++++++++++ scripts/release/verify-packed-install.ts | 81 ++++++++++++------------ 7 files changed, 182 insertions(+), 124 deletions(-) create mode 100644 scripts/release/process.ts create mode 100644 scripts/release/tarball.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index affdd3c900..6c7a7489f3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -79,8 +79,14 @@ jobs: - 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 + - name: Verify packed install - run: pnpm run release:verify-packed-install --family dsh --from dist/npm + run: pnpm run release:verify-packed-install --family dsh --from dist/npm --from dist/npm-vendor - uses: actions/upload-artifact@v4 with: diff --git a/scripts/release/bump.ts b/scripts/release/bump.ts index 83e1b3a2da..9755fd00c4 100644 --- a/scripts/release/bump.ts +++ b/scripts/release/bump.ts @@ -13,11 +13,11 @@ * the tag after the commit merges. CI never writes to the repository. */ -import { spawnSync } from 'node:child_process' 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 { capture } from './process.ts' /** Files npm publishes whether or not `files` lists them. */ const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as const @@ -25,21 +25,6 @@ const ALWAYS_PUBLISHED = ['package.json', 'README*', 'LICENSE*', 'LICENCE*'] as /** Release types the dsh family accepts besides an explicit version. */ const RELEASE_TYPES = ['major', 'minor', 'patch'] as const -/** - * Run a command and fail the process on a non-zero exit. - * @param command - executable name. - * @param args - command arguments. - * @returns The captured stdout, trimmed. - */ -function run(command: string, args: readonly string[]): string { - const result = spawnSync(command, [...args], { encoding: 'utf8' }) - if (result.error !== undefined) throw result.error - 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() -} - /** * Split a version into its release numbers, discarding any prerelease segment. * @param version - the current version. @@ -106,7 +91,7 @@ function reachesPayload(member: ReleaseMember, path: string): boolean { */ function lastPublishedTag(family: ReleaseFamily, member: ReleaseMember): string | undefined { const prefix = family.tagFor(member).replace(/-v[^-]*$/, '-v') - const tags = run('git', ['tag', '--list', `${prefix}*`, '--sort=-v:refname']).split('\n').filter(line => line !== '') + const tags = capture('git', ['tag', '--list', `${prefix}*`, '--sort=-v:refname']).split('\n').filter(line => line !== '') return tags[0] } @@ -119,7 +104,7 @@ function lastPublishedTag(family: ReleaseFamily, member: ReleaseMember): string function changedSincePublication(family: ReleaseFamily, member: ReleaseMember): boolean { const tag = lastPublishedTag(family, member) if (tag === undefined) return true - const changed = run('git', ['diff', '--name-only', `${tag}..HEAD`, '--', member.directory]) + const changed = capture('git', ['diff', '--name-only', `${tag}..HEAD`, '--', member.directory]) .split('\n').filter(line => line !== '') return changed.some(path => reachesPayload(member, path)) } @@ -176,7 +161,7 @@ function main(): void { const dryRun = values['dry-run'] if (!dryRun) { for (const { member, version } of planned) writeVersion(root, member, version) - run('pnpm', ['install', '--lockfile-only']) + capture('pnpm', ['install', '--lockfile-only']) } const summary = sharedVersion @@ -188,8 +173,8 @@ function main(): void { console.log('release bump: dry run, nothing written') return } - run('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => join(entry.member.directory, 'package.json'))]) - run('git', ['commit', '-m', `release(${family.id}): ${summary}`]) + capture('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => join(entry.member.directory, 'package.json'))]) + capture('git', ['commit', '-m', `release(${family.id}): ${summary}`]) // The dsh family tags once for its shared version; vendor tags each package. const tags = [...new Set(planned.map(entry => family.tagFor({ ...entry.member, version: entry.version })))] console.log('release bump: committed. After this merges to master, tag it:') diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts index 1b128817b9..5c50ebca4b 100644 --- a/scripts/release/pack.ts +++ b/scripts/release/pack.ts @@ -7,41 +7,16 @@ * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). */ -import { spawnSync } from 'node:child_process' 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 { 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' -/** Name of the file the publish step reads to learn the upload order. */ -export const PUBLISH_ORDER_FILE = 'publish-order.txt' - -/** - * Run a command, inheriting stdio, and fail the process on a non-zero exit. - * @param command - executable name. - * @param args - command arguments. - */ -function run(command: string, args: readonly string[]): void { - const result = spawnSync(command, [...args], { stdio: 'inherit' }) - if (result.error !== undefined) throw result.error - if (result.status !== 0) throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}`) -} - -/** - * List a tarball's members. - * @param tarball - absolute tarball path. - * @returns Every path inside the archive. - */ -function tarballFiles(tarball: string): string[] { - const result = spawnSync('tar', ['-tzf', tarball], { encoding: 'utf8' }) - if (result.error !== undefined) throw result.error - if (result.status !== 0) throw new Error(`tar -tzf ${tarball} exited with ${String(result.status)}:\n${result.stderr}`) - return result.stdout.split('\n').filter(line => line !== '') -} - /** * Pack one member and check what its tarball carries. * @param family - the release family being packed. diff --git a/scripts/release/process.ts b/scripts/release/process.ts new file mode 100644 index 0000000000..3e8c6943bd --- /dev/null +++ b/scripts/release/process.ts @@ -0,0 +1,65 @@ +/** + * 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' + +/** 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)}`) +} diff --git a/scripts/release/publish.ts b/scripts/release/publish.ts index 68dae1701a..bd0b2d8552 100644 --- a/scripts/release/publish.ts +++ b/scripts/release/publish.ts @@ -12,13 +12,13 @@ * the same artifact safe. */ -import { spawnSync } from 'node:child_process' 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 { PUBLISH_ORDER_FILE } from './pack.ts' +import { attempt, run } from './process.ts' +import { packedIdentity, readPublishOrder } from './tarball.ts' /** npm access level for every package this repository publishes. */ const ACCESS = 'restricted' @@ -28,22 +28,6 @@ type RegistryState = | { readonly kind: 'absent' } | { readonly kind: 'present'; readonly integrity: string } -/** - * Read a packed tarball's own manifest. - * @param tarball - absolute tarball path. - * @returns The packed `package.json` name and version. - */ -function packedIdentity(tarball: string): { name: string; version: string } { - const result = spawnSync('tar', ['-xOzf', tarball, 'package/package.json'], { encoding: 'utf8' }) - if (result.error !== undefined) throw result.error - if (result.status !== 0) throw new Error(`cannot read ${tarball}:\n${result.stderr}`) - const manifest: unknown = JSON.parse(result.stdout) - 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 } -} - /** * The subresource integrity string npm records for a tarball. * @param tarball - absolute tarball path. @@ -60,8 +44,7 @@ function integrityOf(tarball: string): string { * @returns The registry state for that version. */ function registryState(name: string, version: string): RegistryState { - const result = spawnSync('npm', ['view', `${name}@${version}`, 'dist.integrity', '--json'], { encoding: 'utf8' }) - if (result.error !== undefined) throw result.error + 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' } @@ -74,19 +57,6 @@ function registryState(name: string, version: string): RegistryState { return { kind: 'present', integrity: parsed } } -/** - * Publish one tarball. - * @param tarball - absolute tarball path. - * @param version - the version being published; a prerelease never takes `latest`. - */ -function publish(tarball: string, version: string): void { - const args = ['publish', tarball, '--access', ACCESS] - if (version.includes('-')) args.push('--tag', 'next') - const result = spawnSync('npm', args, { stdio: 'inherit' }) - if (result.error !== undefined) throw result.error - if (result.status !== 0) throw new Error(`npm publish ${tarball} exited with ${String(result.status)}`) -} - /** Publish the family named by `--family` from the directory named by `--from`. */ function main(): void { const { values } = parseArgs({ @@ -99,11 +69,10 @@ function main(): void { const family = releaseFamily(values.family) const directory = resolve(process.cwd(), values.from) - const order = readFileSync(join(directory, PUBLISH_ORDER_FILE), 'utf8').split('\n').filter(line => line !== '') let published = 0 let skipped = 0 - for (const filename of order) { + for (const filename of readPublishOrder(directory)) { const tarball = join(directory, filename) const { name, version } = packedIdentity(tarball) const state = registryState(name, version) @@ -120,7 +89,9 @@ function main(): void { skipped += 1 continue } - publish(tarball, version) + // A prerelease version never takes the latest dist-tag. + const tagArgs = version.includes('-') ? ['--tag', 'next'] : [] + run('npm', ['publish', tarball, '--access', ACCESS, ...tagArgs]) published += 1 } 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 index d50e876f06..7b970212ab 100644 --- a/scripts/release/verify-packed-install.ts +++ b/scripts/release/verify-packed-install.ts @@ -1,23 +1,28 @@ /** - * Install a packed release family into a throwaway consumer outside the - * repository and drive its installed executable with plain Node. + * Install packed tarballs into a throwaway consumer outside the repository and + * drive the installed executable with plain Node. * - * Everything the packed tarballs need comes from the tarballs themselves: the - * consumer declares every member as a `file:` dependency, so the only registry - * traffic is for external dependencies. 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 + * 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/proposed/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 { spawnSync } from 'node:child_process' import { mkdtempSync, 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, tarballName, type ReleaseMember } from './families.ts' +import { releaseFamily } from './families.ts' +import { capture } from './process.ts' +import { packedIdentity, readPublishOrder } from './tarball.ts' /** * Environment for the installed artifact: no host Node hooks, no host DeepSeek @@ -38,63 +43,61 @@ function consumerEnvironment(consumerRoot: string): NodeJS.ProcessEnv { } /** - * Run a command in the consumer and fail the process on a non-zero exit. - * @param command - executable name. - * @param args - command arguments. - * @param cwd - working directory. - * @param env - child environment. - * @returns The captured stdout, trimmed. + * Every packed tarball in the given directories, as `file:` dependency entries. + * @param directories - absolute pack output directories. + * @returns Package name to tarball file URL, and the version each carries. */ -function run(command: string, args: readonly string[], cwd: string, env: NodeJS.ProcessEnv): string { - const result = spawnSync(command, [...args], { cwd, env, encoding: 'utf8' }) - if (result.error !== undefined) throw result.error - if (result.status !== 0) { - throw new Error(`${command} ${args.join(' ')} exited with ${String(result.status)}:\n${result.stdout}\n${result.stderr}`) +function packedDependencies(directories: readonly string[]): Map { + const dependencies = new Map() + for (const directory of directories) { + for (const filename of readPublishOrder(directory)) { + const tarball = join(directory, filename) + const { name, version } = packedIdentity(tarball) + dependencies.set(name, { url: pathToFileURL(tarball).href, version }) + } } - return result.stdout.trim() + return dependencies } -/** Install the family named by `--family` from `--from` and drive its entry. */ +/** Install every tarball under `--from` and drive the `--family` entry. */ function main(): void { const { values } = parseArgs({ - options: { family: { type: 'string' }, from: { type: 'string' } }, + options: { family: { type: 'string' }, from: { type: 'string', multiple: true } }, allowPositionals: false, }) - if (values.family === undefined || values.from === undefined) { - throw new Error('usage: verify-packed-install.ts --family --from ') + 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 - const root = process.cwd() - const packed = resolve(root, values.from) - const members: ReleaseMember[] = family.members(root) - 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 { - const dependencies = Object.fromEntries(members.map(member => - [member.name, pathToFileURL(join(packed, tarballName(member))).href])) writeFileSync(join(consumerRoot, 'package.json'), `${JSON.stringify({ name: `dsh-packed-install-${family.id}`, version: '0.0.0', private: true, - dependencies, + 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(members.length)} tarball(s) into ${consumerRoot}`) - run('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false'], consumerRoot, environment) + console.log(`release verify-packed-install: installing ${String(packed.size)} tarball(s) into ${consumerRoot}`) + capture('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false'], { cwd: consumerRoot, env: environment }) const bin = join(consumerRoot, 'node_modules', ...entry.packageName.split('/'), entry.binPath) - const version = run(process.execPath, [bin, '--version'], consumerRoot, environment) - const expected = members.find(member => member.name === entry.packageName)?.version - if (version !== expected) { - throw new Error(`installed ${entry.packageName} --version reported ${JSON.stringify(version)}, expected ${String(expected)}`) + 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 { From bcc4890038eaa13db84b0ce23ec00ffa6f2ba6ba Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:01:40 +0800 Subject: [PATCH 048/113] fix(release): follow the workspace protocol in the invariant fixture and knip config The package-invariant spec built its fixture with the range the rule no longer accepts. knip stopped needing the tar ignore entry once the tarball helpers became the one place that spawns it. --- knip.json | 1 - scripts/package-invariants.spec.ts | 2 +- 2 files changed, 1 insertion(+), 2 deletions(-) diff --git a/knip.json b/knip.json index 44f45da1ad..2f755dbb5f 100644 --- a/knip.json +++ b/knip.json @@ -9,7 +9,6 @@ "musl-gcc", "python3", "sandbox-exec", - "tar", "taskkill", "where.exe" ], diff --git a/scripts/package-invariants.spec.ts b/scripts/package-invariants.spec.ts index 59e56976e6..2763341b9e 100644 --- a/scripts/package-invariants.spec.ts +++ b/scripts/package-invariants.spec.ts @@ -49,7 +49,7 @@ function fixture(options: { }, files: ['lib/index.js', 'lib/invariant.js'], peerDependencies: options.invariantDependency === false ? {} : { - '@deepseek-ai/dsh-invariants': '^0.0.1', + '@deepseek-ai/dsh-invariants': 'workspace:^', }, devDependencies: options.invariantDependency === false ? {} : { '@deepseek-ai/dsh-invariants': 'workspace:^', From d9dcf5a48481b807050cd5b51b87aa8c3c1f6e22 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:26:36 +0800 Subject: [PATCH 049/113] fix(release): close the review findings on the release sequences The root manifest carries the dsh family version. bump writes it with the members, because the workspace constraint requires them to match, and that constraint now accepts a prerelease segment: without both, release:dsh 0.0.2 left the root behind and 0.0.1-rc.1 could satisfy neither check. The Landlock workflow no longer passes --access public, which overrode the restricted publishConfig this repository just adopted for those packages. Vendored change detection reads build inputs when a package publishes build output, and vendor/cordis publishes the src its export map already pointed at: its lib/ is untracked, so a real source edit read as 'nothing changed' and the next publish would fail on a version whose bytes moved. The next version also takes the last published version as its baseline, so a re-sync that restores a lower upstream version cannot recompute a version already on the registry, and bump confirms the registry carries what the newest tag names. Tag prefixes are constructed rather than recovered from a full tag, which a hyphenated version defeated. Pack runs group per ref so concurrent pull requests stop displacing each other, the publish job carries the global group, and the unused id-token permission is gone. Every release script sits behind an entry guard, which is what lets the pure judgements carry tests: tag naming, publish order and cycle reporting, version arithmetic, payload policy, and the change judgement. The Agent Note moves to implemented and states what shipped: one probe command, the registry confirmation that now exists, and byte reproducibility recorded as assumed rather than measured. --- ...2026-08-10-npm-release-sequences.i18n.yaml | 6 + .../2026-08-10-npm-release-sequences.md | 160 +++++++++++ .../2026-08-10-npm-release-sequences.zh.md | 160 +++++++++++ ...2026-08-10-npm-release-sequences.i18n.yaml | 6 - .../2026-08-10-npm-release-sequences.md | 185 ------------- .../2026-08-10-npm-release-sequences.zh.md | 188 ------------- .github/workflows/landlock-run-release.yml | 4 +- .github/workflows/release-vendor.yml | 8 +- .github/workflows/release.yml | 9 +- apps/cli/package.json | 8 +- packages/bundle/base/package.json | 4 +- pnpm-lock.yaml | 12 +- scripts/check-workspace-constraints.ts | 6 +- scripts/release/bump.ts | 255 +++++++++++++----- scripts/release/families.spec.ts | 146 ++++++++++ scripts/release/families.ts | 31 ++- scripts/release/pack.ts | 6 +- scripts/release/process.ts | 17 ++ scripts/release/publish.ts | 6 +- scripts/release/verify-packed-install.ts | 6 +- scripts/release/verify.ts | 5 +- vendor/README.md | 1 + vendor/cordis/package.json | 3 +- 23 files changed, 747 insertions(+), 485 deletions(-) create mode 100644 .agents/notes/implemented/process/2026-08-10-npm-release-sequences.i18n.yaml create mode 100644 .agents/notes/implemented/process/2026-08-10-npm-release-sequences.md create mode 100644 .agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md delete mode 100644 .agents/notes/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml delete mode 100644 .agents/notes/proposed/process/2026-08-10-npm-release-sequences.md delete mode 100644 .agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md create mode 100644 scripts/release/families.spec.ts 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..4246b4c62a --- /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: 23c5f26c7be2b87d1ef2edffdd3f79a87cf1f9a1 +2026-08-10-npm-release-sequences.zh.md: 77b772460d0bf6b66ae6924c7bab1dfe0860b401 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..23c5f26c7b --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -0,0 +1,160 @@ +# 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. + +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. + +### 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..77b772460d --- /dev/null +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.zh.md @@ -0,0 +1,160 @@ +# 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` 并撞上已发版本。 + +只发改动过的包,而变更判据不引入新的状态文件:**每包一个 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 族,发布的仍只有自己那一份。 + +### 本次带出的仓库改动 + +| 项 | 内容 | +|---|---| +| 发布集 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/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.i18n.yaml deleted file mode 100644 index 76798fc636..0000000000 --- a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.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/proposed/process/2026-08-10-npm-release-sequences.md -2026-08-10-npm-release-sequences.md: bfe4ccaeddee3713445a3ffb77d575e4af207ae2 -2026-08-10-npm-release-sequences.zh.md: 3df42748429ac62e8131bfe22e4ebaafb4943b32 diff --git a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md deleted file mode 100644 index bfe4ccaedd..0000000000 --- a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md +++ /dev/null @@ -1,185 +0,0 @@ -# Agent Note: Private npm publication as three independent sequences - -Status: proposed - -English | [中文](2026-08-10-npm-release-sequences.zh.md) - -## Problem - -This repository holds three unrelated groups of publishable packages and no channel that sends 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 that already have `landlock-run-release.yml`. 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 sit in the current state. All 217 workspace manifests set `private: true`, which npm refuses to publish. The subtler one is 933 hard-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 excludes `0.0.1-rc.1` too. Those 933 entries have never failed only because the version has never left `0.0.1`. - -The existing `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, but its tarball payload validation and installed-artifact probes are verified parts. - -## Proposal - -### Three independent sequences - -`packages/`, `vendor/`, and `native/` each get 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 whole family, `0.0.x` | `dsh-v` | `release.yml` (new) | -| vendored framework | the nine `vendor/*` packages | each package on its own version line | `vendor--v` (one per package) | `release-vendor.yml` (new) | -| native | `native/landlock-run/packages/*` | its own `0.0.x` | `landlock-run-v` | `landlock-run-release.yml` (unchanged) | - -All three publish privately to the `@deepseek-ai` scope on npmjs.com (`npm publish --access restricted`). - -### Versions land in the repository from a local command; CI only checks and uploads - -Each sequence has one `bump and commit` command: derive the target version, write it into the relevant manifests, run `pnpm install --lockfile-only`, self-check immediately, then `git add` the manifests and the lockfile and commit. The published version is therefore readable from the repository, and "which version went out" is never a question. A human creates the tag after the commit merges to master; CI never writes to the repository and needs no write permission. - -The dsh sequence shares one version across the family and accepts `major | minor | patch | x.y.z`. A prerelease version such as `0.0.1-rc.1` drives pack, the installed-artifact probes, and one real private publication end to end first; numbered versions like `0.0.1` and `0.0.2` follow once that passes. The dist-tag decision is the one this repository already makes in `landlock-run-release.yml`: 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 upstream version with its prerelease segment dropped and its patch incremented: - -| 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 | - -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 runs `git diff ..HEAD -- vendor/`; a difference means patch+1, no difference means skip, and a missing tag means the first publication from the table above. The diff considers only paths that reach the tarball (the `files` rules in `scripts/publication-payload.ts`), so editing a comment inside a vendored package does not trigger a release. - -A tag is a commit pointer, not proof of publication — a tag created for a publication that then failed must be recognizable. So bump also asks the registry whether the version that tag names exists, and fails for a human to resolve when it does not, rather than guessing. Querying a private package needs authentication; the check is skipped on a machine that is not logged in and enforced in CI. - -The dependency ranges *inside* the nine vendored packages need no rewrite: `^1.8.1` admits `1.8.2` and `^1.0.0-rc.5` admits `1.0.1`, so patch+1 always lands inside the range. - -### Publication runs only on GitHub, and the registry decides what goes out - -Publication runs only from GitHub Actions; there is no local publication path. That makes the registry check a mandatory CI step instead of something with a bypass for an unauthenticated machine. - -Publish reads no tag and no manifest of "what this release includes". For each member it compares the manifest 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 is the point of the rule: it 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. The dsh sequence behaves identically with one version: the difference is either the whole set or nothing. - -The third state depends on a reproducible build — packing the same commit twice must produce the same bytes. That must be measured, not assumed: if `pnpm run build` embeds absolute paths or timestamps, integrity drifts while content is unchanged and the third state reports a false failure. Before this lands, pack the same commit twice in CI and compare integrity; if it is not reproducible, compare per-file content hashes inside the tarball instead and exclude the fields that drift. - -### Rewrite workspace-internal references to `workspace:^`, once - -Every reference to a workspace member becomes `workspace:^`, so `pnpm pack` substitutes a range that matches the target version: - -| Surface | Count | Effect | -|---|---|---| -| sibling dsh `peerDependencies` | 933 | `0.0.2` and `0.0.1-rc.1` both get a matching range | -| dep / peer / devDep pointing at vendor | 105 + 221 + 218 | no dsh-side rewrite after a vendor patch+1, and no range that goes stale as vendor increments | - -`scripts/check-workspace-constraints.ts` currently asserts that the vendor peer and dev ranges are equal; both become `workspace:^`, so the assertion still holds but its wording changes with it. - -This is what makes "no dependency rewriting at publication time" possible: publication does one thing, which is packing bytes. - -### 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 family description and one workflow lane, not changing the core. - -| Object | Responsibility | -|---|---| -| `ReleaseFamily` | a family's identity: member discovery, version policy, tag naming, publish target. A new release family lands here | -| `ReleaseMember` | one publishable package: directory, manifest, family, position in publish order | -| `VersionPolicy` | where the version comes from. `SharedSemver` (dsh: one version for the family) and `PerPackageChanged` (vendor: change judged by tag, prerelease dropped, patch incremented) | -| `ReleaseSet` | a family's members in topological order over `dependencies`, ties broken by package name for determinism | -| `PackedBundle` | the tarballs plus `publish-order.txt` and its metadata: the only handoff between pack and publish | -| `PublishTarget` | registry, access, dist-tag, credential source. The dist-tag derives from the version's shape | -| `VersionInvariant` | the family's versions agree; publication runs from the family's tag; the tag version equals the package version; the target version is absent from the registry | -| `PayloadInvariant` | tarball content validation, reusing `scripts/publication-payload.ts` | -| `InstalledProbe` | a throwaway consumer outside the repository installs from the tarballs and drives the installed entry with plain Node: `dsh --version`, `dsh --dump-default-config`, and one TUI startup to ready and exit. Moved over from `scripts/publish-npm-baseline.ts` | - -### Workflow shape: pack everything at once, then publish as one set - -The shape comes from the reference flow (`release.yml` and `scripts/pack-release.mjs` in node-addon-require-builtin): the `pack` job walks the whole release set once, running `pnpm --dir pack --pack-destination ` per member, writes `publish-order.txt`, and uploads that directory as **one** artifact; the `publish` job downloads that artifact and runs `npm publish` per entry in `publish-order.txt`. The release set is one unit — half the packages can never reach the registry while the other half is still building. - -The `pack` job carries no credentials: install, verify, build, pack, installed-artifact verification, upload-artifact. The `publish` job sits behind the `npm-publish` environment for human approval, runs `setup-node` and `download-artifact` only, and **neither checks out nor builds** — it uploads the bytes pack produced. Checkout uses `fetch-depth: 0`, because the vendor change judgement reads history and tags. - -The environment is the only brake in the flow: pack has no credentials and can be rehearsed freely, and only publish stops for approval. GitHub needs an `NPM_TOKEN` secret (an automation token with publish rights on the scope) and an `npm-publish` environment (required reviewers, allowed tags limited to `dsh-v*`, `vendor-*`, and `landlock-run-v*`). - -### Pull requests run as far as pack - -The reference flow only has `workflow_dispatch`, so it verifies nothing on a pull request. Here `pull_request` runs the full pack: install, verify, build, pack per member, upload the tarball artifact. What it proves is that this release set still packs completely; it uses no credentials, touches no registry, and runs for pull requests from forks. The artifacts' own correctness is covered by existing tests and is not repeated at this layer. - -The publication path is exercised from master: `push: master` runs the same pack rehearsal as a post-merge regression, and `workflow_dispatch` with `publish: true` performs a real publication from a tag. - -### Repository changes - -| Item | Content | -|---|---| -| release-set manifests | drop `private: true`; add `publishConfig.access: restricted` and `repository` (`git+https://github.com/deepseek-ai/deepseek-harness.git` plus each package's `directory`) | -| release-set boundary | every member of `packages/*/*`, `apps/*`, and `vendor/*`; no smaller selection | -| dependency protocol | workspace-internal references become `workspace:^`, with `check-workspace-constraints.ts` updated | -| root `AGENTS.md` | it states that vendored packages are rescoped and `private: true`; vendor now publishes, so that convention changes | -| `vendor/README.md` | its manifest table records the upstream version, kept distinct from the version we publish | -| the three native packages | `publishConfig.access` moves from `public` to `restricted`; they have never been published, so no anonymous install path exists to preserve | - -### Relationship to the existing proposal - -This Agent Note replaces the version scheme and the release-set boundary in [artifact-first npm baseline publication](2026-08-04-artifact-first-npm-baseline-publication.md): its `--` prerelease versions and `dev-` dist-tag are not adopted, and vendor is no longer excluded from the release set. What both agree on stays: 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 at all: 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.** This is what the reference flow does: unpack the tarballs into a tree and drive it with plain Node. It bypasses version-range resolution, so in principle it cannot prove that 200-odd interdependent packages install from a registry. Running a local registry in CI to cover that layer was proposed and rejected: artifact correctness is already 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. - -**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. The release set is therefore all of `packages/*/*`, `apps/*`, and `vendor/*`; 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 rewriting them to `workspace:^` once, 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. The reference flow leaves bump and commit to local commands and lets CI check and upload. - -## Acceptance criteria - -1. The three sequences release independently: releasing dsh modifies no vendor or native manifest, and the converse holds. -2. `pnpm release:dsh ` performs bump and commit in one command, and the resulting commit carries the family's manifests and the lockfile and self-checks immediately. -3. `pnpm release:vendor` increments the patch only for packages whose tarball content changed since their `vendor--v*` tag, and leaves the manifests of unchanged packages alone. -4. `pull_request` runs the full pack and produces the tarball artifact, with no credentials and no access to a real registry, including for pull requests from forks. -5. `push: master` runs the same pack rehearsal; a real publication can only come from `workflow_dispatch` with `publish: true` from that family's tag. -6. Re-running publish over one artifact republishes no existing version, and when a version exists whose tarball integrity differs, publish fails and names the package. -7. A throwaway consumer outside the repository installs `@deepseek-ai/dsh@0.0.1-rc.1` and drives `--version`, `--dump-default-config`, and one TUI startup with plain Node. -8. Every workspace-internal reference is `workspace:^`, the packed tarballs carry no `workspace:` remnant, and no range points at a version that does not exist. -9. No release-set member sets `private: true`, and each one sets `publishConfig.access: restricted`. - -## Risks - -**Tags drifting from the registry.** A tag created for a publication that then failed makes the next bump treat the package as published. Bump asks the registry whether the version the tag names exists and fails when it does not; on a machine that is not logged in to the private registry that check is skipped, and only the same check in CI catches it. - -**The change judgement depends on visible tags.** A shallow clone, or a checkout without tags, breaks the vendor judgement and degrades it to "publish everything for the first time". `fetch-depth: 0` is a precondition of the judgement, not an optimization. - -**`workspace:^` touches a large surface.** It rewrites 1477 dependency declarations at once. It does not change local resolution — pnpm already resolves from the workspace — but it changes the ranges that go out, and the workspace constraint gate changes with it. - -**The visibility cost of private packages.** After `--access restricted`, every consumer — CI, sandbox e2e, and outside users — needs scope credentials to install. The three native packages move to `restricted` as well; they have never been published, so no existing anonymous install path is cut off. - -**The `repository` field names a different organization than the one running the workflow.** The release set points consumers at `github.com/deepseek-ai/deepseek-harness`, which is not where these workflows run. 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. - -**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. Driving the complete path with `0.0.1-rc.1` first is the only mitigation, which is why numbered versions wait for that to pass. diff --git a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md b/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md deleted file mode 100644 index 3df4274842..0000000000 --- a/.agents/notes/proposed/process/2026-08-10-npm-release-sequences.zh.md +++ /dev/null @@ -1,188 +0,0 @@ -# Agent Note: 三条独立序列的私有 NPM 发布 - -Status: proposed - -[English](2026-08-10-npm-release-sequences.md) | 中文 - -## 问题 - -这个仓库有三组互不相干的可发布包,但没有任何发布通道把它们送上 registry。 - -`packages/*/*` 与 `apps/*` 组成 `@deepseek-ai/dsh` 的运行时闭包;`vendor/*` 是九个 rescope 过的 Cordis 框架包,各自带着上游的版本号;`native/landlock-run/packages/*` 是 Linux 平台包,已有自己的 `landlock-run-release.yml`。三组的版本基线、变更节奏和构建要求都不同: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 规定不带预发布段的范围排除预发布版本)。这 933 条至今没出事,只因为版本一直停在 `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`) | 全族一个 `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 下的**私有包**(`npm publish --access restricted`)。native 三个包现在写的是 `access: public`,要改成 `restricted`。 - -### 版本由本地命令写进仓库,CI 只核对与上传 - -每条序列有一条 `bump and commit` 命令:算出目标版本 → 写进相关 manifest → `pnpm install --lockfile-only` → 立刻自检 → `git add` manifest 与 lockfile → commit。发布版本因此在仓库里查得到,不存在「发出去的是哪个版本说不清楚」。tag 由人工在合入 master 后打,CI 不写仓库、不需要写权限。 - -dsh 序列全族共用一个版本,接受 `major | minor | patch | x.y.z` 三种入参与显式版本号。先用 `0.0.1-rc.1` 这类预发布号把 pack、仓外安装探针、真实私有发布跑通一遍,验证通过后再发 `0.0.1`、`0.0.2` 这样的数字版本。dist-tag 沿用本仓 `landlock-run-release.yml` 已有的判定:版本带预发布段就 `--tag next`,否则进 `latest`。 - -### vendor:谁改了谁发版,tag 就是账本 - -vendor 九包加了 scope 之后与上游脱钩,但保留各自的版本线。发布版本 = 去掉预发布段后 patch+1,首发目标: - -| 包 | 上游版本 | 首发版本 | -|---|---|---| -| `@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 | - -只发改动过的包。变更判据不引入新的状态文件:**每包一个 tag,tag 就是「上次发布到哪个 commit」的记录**。bump 对每个包取最新的 `vendor-<包名>-v*` tag,`git diff <该 tag>..HEAD -- vendor/<目录>` 有差异就 patch+1,没差异就跳过;查不到 tag 就按上表首发。差异只看会进 tarball 的路径(复用 `scripts/publication-payload.ts` 的 `files` 规则),改动 vendor 内的注释不触发发版。 - -tag 只是 commit 指针,不是「已发布」的证明——打了 tag 而 publish 失败的情况必须能识别。所以 bump 还要向 registry 核对「tag 所指版本确实存在」,不一致时明确失败交人处理,不让脚本猜。私有包查询需要鉴权,本机未登录时跳过这条核对,CI 里强制执行。 - -vendor 九包**内部**的依赖范围不用改:`^1.8.1` 容纳 `1.8.2`、`^1.0.0-rc.5` 容纳 `1.0.1`,patch+1 永远落在范围内。 - -### publish 只在 GitHub 执行,用 registry 状态决定发什么 - -发布只从 GitHub Actions 执行,没有本机发布路径。这让「向 registry 核对」成为 CI 里的强制步骤,不需要为本机未鉴权的情况留旁路。 - -publish 不读 tag、不读任何清单,对发布集里每个包比较 manifest 版本与 registry 上的已发布状态,按三态处置: - -| 状态 | 处置 | -|---|---| -| registry 上没有该版本 | 发布 | -| 已有该版本,且 tarball 的 sha512 与 registry 记录的 `dist.integrity` 相同 | 跳过,属于同一批产物的重跑 | -| 已有该版本,但 integrity 不同 | 失败退出,报「内容已变但版本未 bump」 | - -第三态是这条规则的目的:它拦住「改了代码却没 bump 版本」。前两态给出的是幂等——同一个 artifact 重跑 publish 不会重复发布,也不需要人工挑拣包。 - -这条规则同时解决了一次发布事件产生多个 vendor tag、而 workflow 只能从一个 ref 触发的矛盾:workflow 不需要从 tag 推断本次该发哪些包。dsh 序列同构处理,它只有一个版本,差集要么全发要么全跳。 - -第三态依赖同输入构建可复现(同一 commit 两次 pack 得到相同字节)。这一点必须实测确认,不能假定:`pnpm run build` 的产物若嵌入绝对路径或时间,integrity 就会在内容未变时漂移,第三态会误报。落地前先在 CI 上对同一 commit 连跑两次 pack 比对 integrity;若不可复现,则把比较下沉到 tarball 内的逐文件内容哈希,并明确排除导致漂移的字段。 - - -### 一次性把 workspace 内部引用改成 `workspace:^` - -仓库里所有指向 workspace 成员的引用统一成 `workspace:^`,由 `pnpm pack` 在发布时替换成匹配目标版本的范围。 - -| 面 | 数量 | 效果 | -|---|---|---| -| dsh 兄弟包 `peerDependencies` | 933 | 发 `0.0.2` 或 `0.0.1-rc.1` 都自动得到匹配的范围 | -| 指向 vendor 的 dep / peer / devDep | 105 + 221 + 218 | vendor patch+1 后不需要改写 dsh 侧引用,范围也不会随 vendor 递增而过期 | - -`scripts/check-workspace-constraints.ts` 现在断言 vendor peer 与 dev 的范围相等,改后两边都是 `workspace:^`,断言仍成立但语义要随之更新。 - -这条是「发布期不做任何依赖改写」的前提:发布期只做一件事——pack 出字节。 - -### 发布族对象 - -领域里的实体是**发布族**:一组共享版本基线与 tag 前缀、可整体发布的包。新增一族等于新增一份族描述加一条 workflow lane,不改核心。 - -| 对象 | 职责 | -|---|---| -| `ReleaseFamily` | 一族的身份:成员发现规则、版本策略、tag 命名、publish 目标。新增发布族在此落地 | -| `ReleaseMember` | 一个可发布包:目录、manifest、族归属、发布顺序位次 | -| `VersionPolicy` | 版本从哪来。`SharedSemver`(dsh:全族一个版本)与 `PerPackageChanged`(vendor:按 tag 判变更、去预发布段后 patch+1) | -| `ReleaseSet` | 一族成员的拓扑序,按 `dependencies` 排、同层按包名排,保证确定性 | -| `PackedBundle` | tarball 集合 + `publish-order.txt` + 元数据清单,是 pack 与 publish 之间唯一的交接物 | -| `PublishTarget` | registry、access、dist-tag、凭据来源。dist-tag 由版本形态派生 | -| `VersionInvariant` | 族内版本自洽;publish 必须从对应 tag 跑;tag 版本等于包版本;待发版本不得已存在于 registry | -| `PayloadInvariant` | tarball 内容校验,复用 `scripts/publication-payload.ts` | -| `InstalledProbe` | 仓外临时 consumer 从 tarball 安装后,用普通 Node 驱动已安装入口:`dsh --version`、`dsh --dump-default-config`、起一次 TUI 到就绪后退出。实现从 `scripts/publish-npm-baseline.ts` 搬运复用 | - -### workflow 形状:一次性 pack 全部,再统一 publish - -照参照流程(node-addon-require-builtin 的 `release.yml` 与 `scripts/pack-release.mjs`)的形状:`pack` job 一趟遍历整个发布集,逐包 `pnpm --dir <目录> pack --pack-destination <同一个目录>`,写出 `publish-order.txt`,整个目录作为**一份** artifact 上传;`publish` job 下载那一份 artifact,按 `publish-order.txt` 逐个 `npm publish`。发布集是一个整体,不存在「一半的包发出去了、另一半还在构建」。 - -`pack` job 无凭据:install → verify → build → pack → 打包后安装验证 → upload-artifact。`publish` job 挂 `environment: npm-publish` 人工审批,只 `setup-node` 加 `download-artifact`,**不 checkout、不 build**,上传的就是 pack 出来的同一份字节。checkout 用 `fetch-depth: 0`,vendor 的变更判据需要历史与 tag。 - -`environment` 是整条流程唯一的刹车:pack 无凭据、可随意排练;只有 publish 会停在审批上。GitHub 侧需要 `NPM_TOKEN` secret(对该 scope 有发布权限的 automation token)与 `npm-publish` environment(required reviewers,允许的 tag 限制为 `dsh-v*`、`vendor-*`、`landlock-run-v*`)。 - -### PR 阶段跑到 pack 为止 - -参照流程只有 `workflow_dispatch`,PR 上什么都验证不了。本仓在 `pull_request` 上跑完整的 pack:install → verify → build → 逐包 pack → 上传 tarball artifact。它证明的是「这个发布集现在能完整打出来」,无凭据、不碰任何 registry,fork 发来的 PR 也能跑。产物本身的正确性由既有测试覆盖,不在 PR 这一层重复。 - -发布路径的测试走 master:`push: master` 跑同一套 pack 排练作为合入后回归,`workflow_dispatch` 带 `publish: true` 从 tag 走真实发布。 - - - -### 仓库改造项 - -| 项 | 内容 | -|---|---| -| 发布集 manifest | 去掉 `private: true`,补 `publishConfig.access: restricted` 与 `repository`(`git+https://github.com/deepseek-ai/deepseek-harness.git` + 各自 `directory`) | -| 发布集边界 | `packages/*/*` + `apps/*` + `vendor/*` 全部成员,不另挑子集 | -| 依赖协议 | workspace 内部引用统一 `workspace:^`,并更新 `check-workspace-constraints.ts` | -| 根 `AGENTS.md` | 现在写着 vendored 包是 rescope 过且 `private: true`,vendor 要发布,这条约定要改 | -| `vendor/README.md` | manifest 表补记上游版本,与我们发布的版本区分开 | -| native 三包 | `publishConfig.access` 从 `public` 改 `restricted`;它们尚未发布过,所以没有匿名安装路径要保 | - -### 与既有提案的关系 - -本 Note 取代 [以产物为先的 NPM 基线发布](2026-08-04-artifact-first-npm-baseline-publication.md) 中的版本方案与发布集边界两部分:那篇的 `-<时间戳>-<短 SHA>` 预发布版本与 `dev-` dist-tag 不再采用,vendor 也不再排除在发布集之外。两篇一致的部分保留:pack 与 publish 分离、publish 只消费已验证的 tarball、payload 与安装后探针作为发布门。 - -## 考虑过的替代方案 - -**`-<时间戳>-<短 SHA>` 版本号。** 曾计划用它做持续 dev 发布。它与「版本必须落进代码库」冲突:版本内嵌 commit SHA,而把版本写回 manifest 会产生新的 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 携带的是它自己那个包的真实版本。 - -**vendor 九包统一到 `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 驱动。它绕过版本范围解析,理论上验证不了「200 多个互相依赖的包能不能从 registry 装起来」。曾提议在 CI 里起本地 registry 补这一层,被否:产物验证已由既有测试覆盖,发布路径的验证放在 master workflow 的排练里,PR 只需证明发布集能完整打出来。 - -**以 `scripts/publish-npm-baseline.ts` 为基础扩展。** 它是本机发布脚本,把 pack 与 publish 放在同一进程,与「无凭据 pack、受保护 publish」的分离相反。它验证过的零件(payload 校验、已安装产物探针)搬运复用,避免 `pnpm run duplication` 判重复。 - -**按入口闭包挑一部分包发。** 从 `@deepseek-ai/dsh` 与 `@deepseek-ai/dsh-frontend` 沿 `dependencies` 爬得到 156 个包,比全量少 61 个。但本仓的插件是 cordis.yml 按名字挂载的,不是被 import 的:`vendor/cordis-plugin-group` 与 `vendor/cordis-plugin-logger-console` 就落在依赖闭包之外,而它们是运行时必需。照代码依赖挑,漏掉的表现是消费方装完起不来,且要额外证明「没漏任何挂载项」。发布集因此取 `packages/*/*` + `apps/*` + `vendor/*` 全部;私有 scope 下多几个包不对外可见。`python/`、根 `examples/`、`docs/` 与 `website/` 不是发布集成员。 - -**一个 workflow 用 `family` 输入选择序列。** 两套版本模型塞进一个文件会让 concurrency group、tag 前缀、排练触发条件全部分叉成条件表达式。一族一个文件更短也更好读。 - -**在发布期改写依赖范围。** 与一次性改成 `workspace:^` 相比,改写逻辑只在 CI 执行过,本机 `pnpm install` 看不见它是否正确,且每次发布都要重跑一遍。 - -**CI 里执行 bump 并把版本推回仓库。** 需要给 workflow 仓库写权限,且发布分支上的版本提交会与人的提交竞争。参照流程把 bump 与 commit 留在本地命令,CI 只核对与上传。 - -## 验收标准 - -1. 三条序列各自可独立发布:发 dsh 不改动 vendor 与 native 的任何 manifest,反之亦然。 -2. `pnpm release:dsh <版本>` 一条命令完成 bump 与 commit,产出的 commit 含全族 manifest 与 lockfile,且立刻自检通过。 -3. `pnpm release:vendor` 只对「自其 `vendor-<包名>-v*` tag 以来 tarball 内容有变化」的包 patch+1,无变化的包 manifest 不被改动。 -4. `pull_request` 上跑完整 pack 并产出 tarball artifact,无凭据、不访问真实 registry,fork 的 PR 也能跑。 -5. `push: master` 跑同一套 pack 排练;真实发布只能由 `workflow_dispatch` 带 `publish: true` 从对应 tag 触发。 -6. publish 重跑同一 artifact 不重复发布已存在的版本;当某个版本已存在而 tarball integrity 不同时,publish 失败并指明是哪个包。 -7. 仓外临时 consumer 安装 `@deepseek-ai/dsh@0.0.1-rc.1` 后,用普通 Node 能跑通 `--version`、`--dump-default-config` 与一次 TUI 启动。 -8. 所有 workspace 内部引用为 `workspace:^`,且 pack 出的 tarball 里没有任何 `workspace:` 残留、没有指向不存在版本的范围。 -9. 发布集内没有 `private: true`,每个成员都有 `publishConfig.access: restricted`。 - -## 风险 - -**tag 与 registry 漂移。** 打了 tag 但 publish 失败,会让下一次 bump 误判该包已发布。缓解手段是 bump 向 registry 核对 tag 所指版本,不一致就失败退出;但本机未登录私有 registry 时这条核对被跳过,此时误判只能由 CI 的同一条核对拦下。 - -**变更判据依赖 tag 可见。** shallow clone 或未拉取 tag 会让 vendor 的判据失效并退化成「全部首发」。`fetch-depth: 0` 是这条判据的前提,不是优化。 - -**`workspace:^` 改动面大。** 一次触及 1477 处依赖声明。它不改变本机解析行为(pnpm 本来就从 workspace 解析),但会改变发布出去的范围写法,且要同步更新 workspace 约束门。 - -**私有包的可见性代价。** `--access restricted` 之后,任何消费方(含 CI、沙箱 e2e、外部使用者)都必须持有 scope 凭据才能安装。native 三包一并转 `restricted`;它们尚未发布过,因此没有既有的匿名安装路径被切断。 - -**`repository` 指向的组织与运行 workflow 的组织不一致。** 发布集让消费方指向 `github.com/deepseek-ai/deepseek-harness`,而这些 workflow 并不跑在那里。用 token 发布不受影响;一旦改用 npm provenance(OIDC),npm 会要求二者一致,届时要么把 `repository` 改指过去,要么从它指向的组织发布。 - -**首发一次性放大。** vendor 首发九包、dsh 首发全闭包,任何 payload 缺陷都会在同一次发布里暴露。用 `0.0.1-rc.1` 先跑一遍完整链路是唯一的缓解手段,正式版本号留给验证通过之后。 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 index dc778f3d21..c8af47251e 100644 --- a/.github/workflows/release-vendor.yml +++ b/.github/workflows/release-vendor.yml @@ -23,7 +23,9 @@ permissions: contents: read concurrency: - group: ${{ github.workflow }} + # 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: @@ -96,9 +98,11 @@ jobs: needs: pack runs-on: ubuntu-24.04 environment: npm-publish + concurrency: + group: Release-publish + cancel-in-progress: false permissions: contents: read - id-token: write steps: # Checkout and install carry the release scripts only; no build step. - uses: actions/checkout@v6 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6c7a7489f3..7659b3b5a3 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -23,8 +23,9 @@ permissions: contents: read concurrency: - # dist-tags are shared registry state; never race two release runs. - group: ${{ github.workflow }} + # 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: @@ -103,9 +104,11 @@ jobs: # 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 - id-token: write steps: # Checkout and install carry the release scripts only. There is no build # step: publication uploads the bytes the pack job produced. diff --git a/apps/cli/package.json b/apps/cli/package.json index 71f64a0f41..b8f0887ac6 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -20,10 +20,10 @@ ], "license": "BSD-3-Clause", "dependencies": { - "@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/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:^", diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index dc54c75012..9c5fc2a5a8 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -41,8 +41,8 @@ } }, "dependencies": { - "@deepseek-ai/cordis-plugin-hmr": "workspace:*", - "@deepseek-ai/cordis-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:^", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 16602cc946..646244da69 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -130,16 +130,16 @@ importers: specifier: workspace:^ version: link:../../vendor/cordis '@deepseek-ai/cordis-plugin-hmr': - specifier: workspace:* + specifier: workspace:^ version: link:../../vendor/hmr '@deepseek-ai/cordis-plugin-include': - specifier: workspace:* + specifier: workspace:^ version: link:../../vendor/include '@deepseek-ai/cordis-plugin-loader': - specifier: workspace:* + specifier: workspace:^ version: link:../../vendor/loader '@deepseek-ai/cordis-plugin-timer': - specifier: workspace:* + specifier: workspace:^ version: link:../../vendor/timer '@deepseek-ai/dsh-agent-tool-mode': specifier: workspace:^ @@ -1205,10 +1205,10 @@ importers: packages/bundle/base: dependencies: '@deepseek-ai/cordis-plugin-hmr': - specifier: workspace:* + specifier: workspace:^ version: link:../../../vendor/hmr '@deepseek-ai/cordis-plugin-timer': - specifier: workspace:* + specifier: workspace:^ version: link:../../../vendor/timer '@deepseek-ai/dsh-agent': specifier: workspace:^ diff --git a/scripts/check-workspace-constraints.ts b/scripts/check-workspace-constraints.ts index 30afce1e9f..cfc10fda49 100644 --- a/scripts/check-workspace-constraints.ts +++ b/scripts/check-workspace-constraints.ts @@ -378,8 +378,10 @@ 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. */ diff --git a/scripts/release/bump.ts b/scripts/release/bump.ts index 9755fd00c4..0f50ea6a9c 100644 --- a/scripts/release/bump.ts +++ b/scripts/release/bump.ts @@ -1,13 +1,13 @@ /** * 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/proposed/process/2026-08-10-npm-release-sequences.md)). + * ([rationale](../../.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md)). * - * The dsh family shares one version: `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 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. @@ -17,14 +17,39 @@ 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 { capture } from './process.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. @@ -36,6 +61,18 @@ function releaseNumbers(version: string): [number, number, number] { 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 next dsh version. * @param current - the family's current shared version. @@ -56,13 +93,19 @@ function nextSharedVersion(current: string, request: string): string { } /** - * The version a vendored package publishes next: its release numbers with the - * patch incremented, which also drops an upstream prerelease segment. - * @param current - the package's current version. + * The version a vendored package publishes next: the higher of its manifest + * version and its last published version, with the patch incremented. + * + * The manifest alone is not the baseline. 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. + * @param current - the package's manifest version. + * @param published - the version its newest tag names, when it has one. * @returns The target version. */ -function nextVendorVersion(current: string): string { - const [major, minor, patch] = releaseNumbers(current) +export function nextVendorVersion(current: string, published: string | undefined): string { + const baseline = published !== undefined && compareReleaseNumbers(published, current) > 0 ? published : current + const [major, minor, patch] = releaseNumbers(baseline) return `${String(major)}.${String(minor)}.${String(patch + 1)}` } @@ -70,57 +113,147 @@ function nextVendorVersion(current: string): string { * 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` (or npm's always-published set) selects it. + * @returns True when `files`, npm's always-published set, or a build input selects it. */ -function reachesPayload(member: ReleaseMember, path: string): boolean { +export function reachesPayload(member: ReleaseMember, path: string): boolean { const relative = path.slice(member.directory.length + 1) const files = member.manifest.files - const patterns = [ - ...ALWAYS_PUBLISHED, - ...Array.isArray(files) ? files.filter((entry): entry is string => typeof entry === 'string') : [], - ] + 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 tag a member published from, or undefined when it never published. + * The newest version a member published, read from its tags. * @param family - the member's family. * @param member - the member. - * @returns The tag name. + * @returns The version, or undefined when the member never published. */ -function lastPublishedTag(family: ReleaseFamily, member: ReleaseMember): string | undefined { - const prefix = family.tagFor(member).replace(/-v[^-]*$/, '-v') - const tags = capture('git', ['tag', '--list', `${prefix}*`, '--sort=-v:refname']).split('\n').filter(line => line !== '') - return tags[0] -} - -/** - * Whether a member's published payload changed since it last published. - * @param family - the member's family. - * @param member - the member. - * @returns True when the member needs a new version. - */ -function changedSincePublication(family: ReleaseFamily, member: ReleaseMember): boolean { - const tag = lastPublishedTag(family, member) - if (tag === undefined) return true - const changed = capture('git', ['diff', '--name-only', `${tag}..HEAD`, '--', member.directory]) +function lastPublishedVersion(family: ReleaseFamily, member: ReleaseMember): string | undefined { + const prefix = family.tagPrefixFor(member) + const [newest] = capture('git', ['tag', '--list', `${prefix}*`, '--sort=-v:refname']) .split('\n').filter(line => line !== '') - return changed.some(path => reachesPayload(member, path)) + return newest === undefined ? undefined : newest.slice(prefix.length) } /** - * Write a version into a member's manifest, preserving formatting and key order. - * @param root - repository root. - * @param member - the member to rewrite. - * @param version - the target version. + * 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 writeVersion(root: string, member: ReleaseMember, version: string): void { - const path = join(root, member.directory, 'package.json') +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": "${member.version}"` - if (!text.includes(line)) throw new Error(`${member.directory}: cannot locate ${line}`) - writeFileSync(path, text.replace(line, `"version": "${version}"`)) + 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. + * @returns The manifests to rewrite. + */ +function planPerPackage(family: ReleaseFamily, members: readonly ReleaseMember[]): 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) + 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. */ @@ -136,21 +269,17 @@ function main(): void { const members = family.members(root) family.verifyVersions(members) - const planned: { member: ReleaseMember; version: string }[] = [] + let planned: PlannedVersion[] let sharedVersion: string | undefined if (family.id === 'dsh') { const request = positionals[0] if (request === undefined) throw new Error('usage: release:dsh ') - const [first] = members - if (first === undefined) throw new Error(`release family ${family.id} has no members`) - sharedVersion = nextSharedVersion(first.version, request) - for (const member of members) planned.push({ member, version: sharedVersion }) + 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') - for (const member of members) { - if (!changedSincePublication(family, member)) continue - planned.push({ member, version: nextVendorVersion(member.version) }) - } + planned = planPerPackage(family, members) } if (planned.length === 0) { @@ -160,25 +289,25 @@ function main(): void { const dryRun = values['dry-run'] if (!dryRun) { - for (const { member, version } of planned) writeVersion(root, member, version) + 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.member.name.replace('@deepseek-ai/', '')} ${entry.version}`).join(', ') + ?? planned.map(entry => `${entry.label.replace('vendor/', '')} ${entry.to}`).join(', ') console.log(`release bump: family ${family.id} -> ${summary}`) - for (const { member, version } of planned) console.log(` ${member.directory}: ${member.version} -> ${version}`) + 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 => join(entry.member.directory, 'package.json'))]) + capture('git', ['add', 'pnpm-lock.yaml', ...planned.map(entry => entry.manifestPath)]) capture('git', ['commit', '-m', `release(${family.id}): ${summary}`]) - // The dsh family tags once for its shared version; vendor tags each package. - const tags = [...new Set(planned.map(entry => family.tagFor({ ...entry.member, version: entry.version })))] console.log('release bump: committed. After this merges to master, tag it:') - for (const tag of tags) console.log(` git tag ${tag} && git push origin ${tag}`) + for (const tag of [...new Set(planned.map(entry => entry.tag).filter(tag => tag !== undefined))]) { + console.log(` git tag ${tag} && git push origin ${tag}`) + } } -main() +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..34faf36ddc --- /dev/null +++ b/scripts/release/families.spec.ts @@ -0,0 +1,146 @@ +/** 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 { 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') + }) +}) + +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 index 78f5de1951..e4c5fda5be 100644 --- a/scripts/release/families.ts +++ b/scripts/release/families.ts @@ -3,7 +3,7 @@ * (`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/proposed/process/2026-08-10-npm-release-sequences.md)). + * ([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. @@ -162,12 +162,22 @@ export abstract class ReleaseFamily { */ 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/`. */ - abstract tagFor(member: ReleaseMember): string + tagFor(member: ReleaseMember): string { + return `${this.tagPrefixFor(member)}${member.version}` + } /** * Check what a member's packed tarball carries. @@ -202,12 +212,11 @@ class DshFamily extends ReleaseFamily { } /** - * The single family tag. - * @param member - any family member; all carry the same version. - * @returns `dsh-v`. + * The single family prefix: every member shares one version, so one tag names it. + * @returns `dsh-v`. */ - tagFor(member: ReleaseMember): string { - return `${this.tagPrefix}${member.version}` + tagPrefixFor(): string { + return this.tagPrefix } /** @@ -243,12 +252,12 @@ class VendorFamily extends ReleaseFamily { } /** - * The member's own tag, because one vendor release can carry several versions. + * A prefix per member, because one vendor release can carry several versions. * @param member - the member being published. - * @returns `vendor--v`. + * @returns `vendor--v`. */ - tagFor(member: ReleaseMember): string { - return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v${member.version}` + tagPrefixFor(member: ReleaseMember): string { + return `${this.tagPrefix}${member.name.replace('@deepseek-ai/', '')}-v` } /** diff --git a/scripts/release/pack.ts b/scripts/release/pack.ts index 5c50ebca4b..47a33a26ac 100644 --- a/scripts/release/pack.ts +++ b/scripts/release/pack.ts @@ -4,14 +4,14 @@ * * 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/proposed/process/2026-08-10-npm-release-sequences.md)). + * ([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 { run } from './process.ts' +import { isEntry, run } from './process.ts' import { PUBLISH_ORDER_FILE, tarballFiles } from './tarball.ts' /** Where pack output lands when `--out` is omitted. */ @@ -58,4 +58,4 @@ function main(): void { console.log(`release pack: family ${family.id}, ${String(order.length)} tarball(s) in ${values.out ?? DEFAULT_OUTPUT}`) } -main() +if (isEntry(import.meta.url)) main() diff --git a/scripts/release/process.ts b/scripts/release/process.ts index 3e8c6943bd..746f24ac36 100644 --- a/scripts/release/process.ts +++ b/scripts/release/process.ts @@ -4,6 +4,8 @@ */ 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 { @@ -63,3 +65,18 @@ export function run(command: string, args: readonly string[], options: RunOption 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 index bd0b2d8552..b180ce1aba 100644 --- a/scripts/release/publish.ts +++ b/scripts/release/publish.ts @@ -6,7 +6,7 @@ * 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/proposed/process/2026-08-10-npm-release-sequences.md)). + * ([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. @@ -17,7 +17,7 @@ import { readFileSync } from 'node:fs' import { join, resolve } from 'node:path' import { parseArgs } from 'node:util' import { releaseFamily } from './families.ts' -import { attempt, run } from './process.ts' +import { attempt, isEntry, run } from './process.ts' import { packedIdentity, readPublishOrder } from './tarball.ts' /** npm access level for every package this repository publishes. */ @@ -98,4 +98,4 @@ function main(): void { console.log(`release publish: family ${family.id}, ${String(published)} published, ${String(skipped)} already present`) } -main() +if (isEntry(import.meta.url)) main() diff --git a/scripts/release/verify-packed-install.ts b/scripts/release/verify-packed-install.ts index 7b970212ab..10fb824147 100644 --- a/scripts/release/verify-packed-install.ts +++ b/scripts/release/verify-packed-install.ts @@ -8,7 +8,7 @@ * 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/proposed/process/2026-08-10-npm-release-sequences.md)). + * ([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 @@ -21,7 +21,7 @@ import { join, resolve } from 'node:path' import { pathToFileURL } from 'node:url' import { parseArgs } from 'node:util' import { releaseFamily } from './families.ts' -import { capture } from './process.ts' +import { capture, isEntry } from './process.ts' import { packedIdentity, readPublishOrder } from './tarball.ts' /** @@ -105,4 +105,4 @@ function main(): void { } } -main() +if (isEntry(import.meta.url)) main() diff --git a/scripts/release/verify.ts b/scripts/release/verify.ts index 98988de18d..1bd74c84d6 100644 --- a/scripts/release/verify.ts +++ b/scripts/release/verify.ts @@ -4,10 +4,11 @@ * * Publication happens only from GitHub Actions, so the tag and publishability * checks are gates on the workflow, not advisory local warnings - * ([rationale](../../.agents/notes/proposed/process/2026-08-10-npm-release-sequences.md)). + * ([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' /** @@ -66,4 +67,4 @@ function main(): void { console.log(`release verify: family ${family.id}, ${String(members.length)} member(s), ${summary}${publishing ? ', publish gates passed' : ''}`) } -main() +if (isEntry(import.meta.url)) main() diff --git a/vendor/README.md b/vendor/README.md index e83f9b4140..0d889e87fd 100644 --- a/vendor/README.md +++ b/vendor/README.md @@ -47,6 +47,7 @@ Keep this log exhaustive — every divergence from upstream must be listed. 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/package.json b/vendor/cordis/package.json index 576ea209a7..ef4df3eb1d 100644 --- a/vendor/cordis/package.json +++ b/vendor/cordis/package.json @@ -27,7 +27,8 @@ "lib/index.js", "lib/types/**/*.d.ts", "lib/types/**/*.d.ts.map", - "bin.js" + "bin.js", + "src" ], "author": "Shigma ", "license": "MIT", From 21db3220d681628581a91ca23ea0fe595f73d482 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:39:01 +0800 Subject: [PATCH 050/113] fix(release): omit optional dependencies from the packed install The Landlock platform packages sit behind optionalDependencies, and npm fails the install on their 404 rather than skipping them: they belong to the native sequence, whose pack needs a musl toolchain and one build per architecture, so this job cannot produce them and holds no credentials to fetch them. A consumer that cannot install them must still start, which is what optional means here. The release spec also gains block bodies where the lint rule rejects returning a void expression from an arrow shorthand. --- .../2026-08-10-npm-release-sequences.i18n.yaml | 4 ++-- .../2026-08-10-npm-release-sequences.md | 2 ++ .../2026-08-10-npm-release-sequences.zh.md | 2 ++ scripts/release/families.spec.ts | 18 +++++++++--------- scripts/release/verify-packed-install.ts | 7 ++++++- 5 files changed, 21 insertions(+), 12 deletions(-) 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 index 4246b4c62a..632b8b3d0d 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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: 23c5f26c7be2b87d1ef2edffdd3f79a87cf1f9a1 -2026-08-10-npm-release-sequences.zh.md: 77b772460d0bf6b66ae6924c7bab1dfe0860b401 +2026-08-10-npm-release-sequences.md: 7efe5c9a1c3aa30d4c0a52aa5983b61a01f514cd +2026-08-10-npm-release-sequences.zh.md: cca853bae3f0b19dbf50aca0f2cf32aa573293ef 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 index 23c5f26c7b..7efe5c9a1c 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -101,6 +101,8 @@ The `pack` job walks the whole release set once, packing each member into one di 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 omits optional dependencies. The Landlock platform packages behind them belong to the native sequence, whose pack needs 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. + ### Repository changes this carried | Item | Content | 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 index 77b772460d..cca853bae3 100644 --- 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 @@ -101,6 +101,8 @@ dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。v dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 `release.yml` 为验证而打包 vendored 族,发布的仍只有自己那一份。 +验证会略去可选依赖。它们背后的 Landlock 平台包属于 native 序列,那条序列的 pack 需要 musl 工具链且每个架构各构建一次,单台 runner 产不出来;而装不到它们的消费方也必须能起——这正是「可选」在这里的含义。 + ### 本次带出的仓库改动 | 项 | 内容 | diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 34faf36ddc..054fd900ac 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -34,8 +34,8 @@ describe('release families', () => { 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() + 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', () => { @@ -45,8 +45,8 @@ describe('release families', () => { { ...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/) + 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', () => { @@ -71,7 +71,7 @@ describe('release families', () => { member('packages/a/right', '@deepseek-ai/dsh-right', { dependencies: { '@deepseek-ai/dsh-left': 'workspace:^' } }), ] - expect(() => dsh.publishOrder(members)).toThrow(/dependency cycle/) + expect(() => { dsh.publishOrder(members) }).toThrow(/dependency cycle/) }) it('applies the harness payload policy to dsh and keeps upstream payloads for vendored packages', () => { @@ -80,10 +80,10 @@ describe('release families', () => { 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'])) + 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/) + 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', () => { @@ -92,7 +92,7 @@ describe('release families', () => { }) it('rejects an unknown family identifier', () => { - expect(() => releaseFamily('native')).toThrow(/unknown release family/) + expect(() => { releaseFamily('native') }).toThrow(/unknown release family/) }) }) diff --git a/scripts/release/verify-packed-install.ts b/scripts/release/verify-packed-install.ts index 10fb824147..f4e5c4b07f 100644 --- a/scripts/release/verify-packed-install.ts +++ b/scripts/release/verify-packed-install.ts @@ -92,7 +92,12 @@ function main(): void { const environment = consumerEnvironment(consumerRoot) console.log(`release verify-packed-install: installing ${String(packed.size)} tarball(s) into ${consumerRoot}`) - capture('npm', ['install', '--no-audit', '--no-fund', '--package-lock=false'], { cwd: consumerRoot, env: environment }) + // Optional dependencies are omitted: the platform packages behind them + // belong to the native release sequence, this job holds no credentials for + // the private scope, and a consumer that cannot install them must still + // start — which is what optional means here. + 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 }) From ae75aca77608533760dbe88c190b1f198b556825 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 01:48:56 +0800 Subject: [PATCH 051/113] fix(release): supply the Landlock entry tarball to the packed install dsh-sandbox-local declares @deepseek-ai/node-addon-landlock-run in dependencies, not optionalDependencies, so omitting optional dependencies left npm resolving it from a registry that does not carry it. The dsh pack job now packs that entry for verification; its own platform packages stay out, being optional and needing a musl toolchain per architecture. The verification reads each directory by its contents rather than a pack order file, because a directory packed only to satisfy a cross-sequence dependency has no release order to describe. --- ...2026-08-10-npm-release-sequences.i18n.yaml | 4 ++-- .../2026-08-10-npm-release-sequences.md | 2 +- .../2026-08-10-npm-release-sequences.zh.md | 2 +- .github/workflows/release.yml | 10 +++++++- scripts/release/verify-packed-install.ts | 23 ++++++++++++------- 5 files changed, 28 insertions(+), 13 deletions(-) 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 index 632b8b3d0d..61728f400d 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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: 7efe5c9a1c3aa30d4c0a52aa5983b61a01f514cd -2026-08-10-npm-release-sequences.zh.md: cca853bae3f0b19dbf50aca0f2cf32aa573293ef +2026-08-10-npm-release-sequences.md: d51054b90aa0acd82d252cdb6e97dc1f3c0e51b5 +2026-08-10-npm-release-sequences.zh.md: 8c2b7b048af407a79f62c5842f0bd04790a61c9a 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 index 7efe5c9a1c..d51054b90a 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -101,7 +101,7 @@ The `pack` job walks the whole release set once, packing each member into one di 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 omits optional dependencies. The Landlock platform packages behind them belong to the native sequence, whose pack needs 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 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 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 index cca853bae3..8c2b7b048a 100644 --- 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 @@ -101,7 +101,7 @@ dsh 族套用仓库的发布 payload 策略(拒绝源码与声明映射)。v dsh 的验证会一并安装 vendored 族的 pack 产物。harness 的包把 vendored 框架声明成 peer,而那些包属于另一条序列,无凭据的 job 无法从私有 registry 取到——所以 `release.yml` 为验证而打包 vendored 族,发布的仍只有自己那一份。 -验证会略去可选依赖。它们背后的 Landlock 平台包属于 native 序列,那条序列的 pack 需要 musl 工具链且每个架构各构建一次,单台 runner 产不出来;而装不到它们的消费方也必须能起——这正是「可选」在这里的含义。 +验证还会打一份 Landlock entry 的 tarball——`dsh-sandbox-local` 把它声明为普通 `dependencies`——同时略去可选依赖。那些可选项背后的平台包需要 musl 工具链且每个架构各构建一次,单台 runner 产不出来;而装不到它们的消费方也必须能起,这正是「可选」在这里的含义。因此验证按目录内容读取 tarball,而不是读发布顺序:一个目录可能只装着为满足跨序列依赖而打出来的包,任何发布顺序都不描述它。 ### 本次带出的仓库改动 diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 7659b3b5a3..cab178a3d2 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -86,8 +86,16 @@ jobs: - 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 + 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: diff --git a/scripts/release/verify-packed-install.ts b/scripts/release/verify-packed-install.ts index f4e5c4b07f..29ec7b851e 100644 --- a/scripts/release/verify-packed-install.ts +++ b/scripts/release/verify-packed-install.ts @@ -15,14 +15,14 @@ * checkout cannot stand in for a missing file here. */ -import { mkdtempSync, rmSync, writeFileSync } from 'node:fs' +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, readPublishOrder } from './tarball.ts' +import { packedIdentity } from './tarball.ts' /** * Environment for the installed artifact: no host Node hooks, no host DeepSeek @@ -44,13 +44,19 @@ function consumerEnvironment(consumerRoot: string): NodeJS.ProcessEnv { /** * Every packed tarball in the given directories, as `file:` dependency entries. - * @param directories - absolute pack output directories. + * + * 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) { - for (const filename of readPublishOrder(directory)) { + 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 }) @@ -92,10 +98,11 @@ function main(): void { const environment = consumerEnvironment(consumerRoot) console.log(`release verify-packed-install: installing ${String(packed.size)} tarball(s) into ${consumerRoot}`) - // Optional dependencies are omitted: the platform packages behind them - // belong to the native release sequence, this job holds no credentials for - // the private scope, and a consumer that cannot install them must still - // start — which is what optional means here. + // 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 }) From 9840d39ba00729e2634a5093f9d11ea3c4b21555 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:36:28 +0800 Subject: [PATCH 052/113] feat(release): rehearse a vendored publication with a prerelease MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit release:vendor --prerelease rc.1 appends the identifier to the version each package would take, so a first publication can be thrown away instead of holding the stable dist-tag: publish already routes any version with a prerelease segment to --tag next. A prerelease does not consume its release numbers, so the stable release that follows takes the same ones rather than skipping a patch. Deciding that needs semver precedence, which git's version sort does not provide — it places 4.0.1-rc.1 above 4.0.1 — so the newest published version is chosen by comparing versions here, numeric prerelease fields numerically. --- ...2026-08-10-npm-release-sequences.i18n.yaml | 4 +- .../2026-08-10-npm-release-sequences.md | 2 +- .../2026-08-10-npm-release-sequences.zh.md | 2 +- scripts/release/bump.ts | 117 +++++++++++++++--- scripts/release/families.spec.ts | 32 ++++- 5 files changed, 136 insertions(+), 21 deletions(-) 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 index 61728f400d..52d911bb5f 100644 --- 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 @@ -2,5 +2,5 @@ # side as of the last confirmed-consistent 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: d51054b90aa0acd82d252cdb6e97dc1f3c0e51b5 -2026-08-10-npm-release-sequences.zh.md: 8c2b7b048af407a79f62c5842f0bd04790a61c9a +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 index d51054b90a..df81756ab8 100644 --- a/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md +++ b/.agents/notes/implemented/process/2026-08-10-npm-release-sequences.md @@ -50,7 +50,7 @@ The vendored packages are decoupled from upstream by their scope but keep their | `@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. +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. 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 index 8c2b7b048a..03269aeb98 100644 --- 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 @@ -50,7 +50,7 @@ vendor 九包加了 scope 之后与上游脱钩,但保留各自的版本线。 | `@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` 并撞上已发版本。 +以「上次发布版本」为基线才扛得住重同步:本仓发过 `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 里:没有它,真实的源码改动会读成「没变化」,而下一次发布会在一个字节已变的版本上失败。 diff --git a/scripts/release/bump.ts b/scripts/release/bump.ts index 0f50ea6a9c..e79b7a0e9c 100644 --- a/scripts/release/bump.ts +++ b/scripts/release/bump.ts @@ -73,6 +73,55 @@ function compareReleaseNumbers(left: string, right: string): number { 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. @@ -93,20 +142,36 @@ function nextSharedVersion(current: string, request: string): string { } /** - * The version a vendored package publishes next: the higher of its manifest - * version and its last published version, with the patch incremented. + * The version a vendored package publishes next. * - * The manifest alone is not the baseline. 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. + * 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): string { - const baseline = published !== undefined && compareReleaseNumbers(published, current) > 0 ? published : current +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) - return `${String(major)}.${String(minor)}.${String(patch + 1)}` + // 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}` } /** @@ -133,9 +198,10 @@ export function reachesPayload(member: ReleaseMember, path: string): boolean { */ function lastPublishedVersion(family: ReleaseFamily, member: ReleaseMember): string | undefined { const prefix = family.tagPrefixFor(member) - const [newest] = capture('git', ['tag', '--list', `${prefix}*`, '--sort=-v:refname']) - .split('\n').filter(line => line !== '') - return newest === undefined ? undefined : newest.slice(prefix.length) + 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) } /** @@ -231,9 +297,14 @@ function planShared( * 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[]): PlannedVersion[] { +function planPerPackage( + family: ReleaseFamily, + members: readonly ReleaseMember[], + prerelease: string | undefined, +): PlannedVersion[] { const planned: PlannedVersion[] = [] for (const member of members) { const published = lastPublishedVersion(family, member) @@ -244,7 +315,7 @@ function planPerPackage(family: ReleaseFamily, members: readonly ReleaseMember[] .split('\n').filter(line => line !== '') if (!changed.some(path => reachesPayload(member, path))) continue } - const to = nextVendorVersion(member.version, published) + const to = nextVendorVersion(member.version, published, prerelease) planned.push({ manifestPath: join(member.directory, 'package.json'), label: member.directory, @@ -256,10 +327,18 @@ function planPerPackage(family: ReleaseFamily, members: readonly ReleaseMember[] return planned } -/** Bump the family named by `--family` and commit; `--dry-run` only reports the plan. */ +/** + * 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' }, 'dry-run': { type: 'boolean', default: false } }, + 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]') @@ -274,12 +353,18 @@ function main(): void { 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') - planned = planPerPackage(family, members) + 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) { diff --git a/scripts/release/families.spec.ts b/scripts/release/families.spec.ts index 054fd900ac..65ebd078ee 100644 --- a/scripts/release/families.spec.ts +++ b/scripts/release/families.spec.ts @@ -2,7 +2,7 @@ import { describe, expect, it } from 'vitest' import { releaseFamily, type ReleaseMember } from './families.ts' -import { nextVendorVersion, reachesPayload } from './bump.ts' +import { compareVersions, nextVendorVersion, reachesPayload } from './bump.ts' /** * A release member standing in for a manifest on disk. @@ -109,6 +109,36 @@ describe('vendored version baseline', () => { 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', () => { From 4cd77a5ad94bf9493203f896be141d0ff8391ab4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 02:46:10 +0800 Subject: [PATCH 053/113] release(vendor): cordis 4.0.1-rc.1, cosmokit 1.8.2-rc.1, group 1.0.1-rc.1, hmr 1.0.16-rc.1, include 1.0.5-rc.1, loader 1.0.1-rc.1, logger-console 1.0.1-rc.1, schemastery 3.18.1-rc.1, timer 1.1.3-rc.1 --- vendor/cordis/package.json | 2 +- vendor/cosmokit/package.json | 2 +- vendor/group/package.json | 2 +- vendor/hmr/package.json | 2 +- vendor/include/package.json | 2 +- vendor/loader/package.json | 2 +- vendor/logger-console/package.json | 2 +- vendor/schemastery/package.json | 2 +- vendor/timer/package.json | 2 +- 9 files changed, 9 insertions(+), 9 deletions(-) diff --git a/vendor/cordis/package.json b/vendor/cordis/package.json index ef4df3eb1d..66a5f1593d 100644 --- a/vendor/cordis/package.json +++ b/vendor/cordis/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/cordis", "description": "Meta-Framework for Modern JavaScript Applications", - "version": "4.0.0-rc.7", + "version": "4.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/vendor/cosmokit/package.json b/vendor/cosmokit/package.json index de1b3947bc..0016ec0bb4 100644 --- a/vendor/cosmokit/package.json +++ b/vendor/cosmokit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/cosmokit", "description": "A collection of common utilities", - "version": "1.8.1", + "version": "1.8.2-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/vendor/group/package.json b/vendor/group/package.json index b5e07fdb66..03baae1394 100644 --- a/vendor/group/package.json +++ b/vendor/group/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/cordis-plugin-group", "description": "Nested plugin group for cordis", - "version": "1.0.0", + "version": "1.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/vendor/hmr/package.json b/vendor/hmr/package.json index 68617b430e..e231701881 100644 --- a/vendor/hmr/package.json +++ b/vendor/hmr/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/cordis-plugin-hmr", "description": "Hot Module Replacement Plugin for Cordis", - "version": "1.0.15", + "version": "1.0.16-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/vendor/include/package.json b/vendor/include/package.json index de1509c92b..1105e13404 100644 --- a/vendor/include/package.json +++ b/vendor/include/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/cordis-plugin-include", "description": "Include files in cordis configurations", - "version": "1.0.4", + "version": "1.0.5-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/vendor/loader/package.json b/vendor/loader/package.json index 62e88b2594..a40bebc668 100644 --- a/vendor/loader/package.json +++ b/vendor/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/cordis-plugin-loader", "description": "Plugin loader for cordis", - "version": "1.0.0-rc.5", + "version": "1.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/vendor/logger-console/package.json b/vendor/logger-console/package.json index 29b4b423ab..ceb1c0bd75 100644 --- a/vendor/logger-console/package.json +++ b/vendor/logger-console/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/cordis-plugin-logger-console", "description": "Console logger exporter for cordis", - "version": "1.0.0", + "version": "1.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/vendor/schemastery/package.json b/vendor/schemastery/package.json index 26cfd9b937..76f3748a5b 100644 --- a/vendor/schemastery/package.json +++ b/vendor/schemastery/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/schemastery", "description": "Type driven schema validator", - "version": "3.18.0", + "version": "3.18.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/vendor/timer/package.json b/vendor/timer/package.json index a8138fae67..211dba0036 100644 --- a/vendor/timer/package.json +++ b/vendor/timer/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/cordis-plugin-timer", "description": "Timer service for cordis", - "version": "1.1.2", + "version": "1.1.3-rc.1", "publishConfig": { "access": "restricted" }, From a5c23dd36a8bb5b371a76197e460c9910712c202 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:20:16 +0800 Subject: [PATCH 054/113] fix: prerelease version --- apps/cli/tests/built-bin.e2e.ts | 5 ++- .../scaffold/create-sdk/tests/create.spec.ts | 6 ++- python/sdk/tests/test_release_version.py | 24 ++++++++++-- scripts/build-python-release.py | 38 +++++++++++++++---- 4 files changed, 61 insertions(+), 12 deletions(-) diff --git a/apps/cli/tests/built-bin.e2e.ts b/apps/cli/tests/built-bin.e2e.ts index 015a729d5a..81acefadc5 100644 --- a/apps/cli/tests/built-bin.e2e.ts +++ b/apps/cli/tests/built-bin.e2e.ts @@ -8,6 +8,9 @@ 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 invalidProvider = fileURLToPath(new URL('./fixtures/invalid-provider.cordis.yml', import.meta.url)) @@ -387,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 }) } 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/python/sdk/tests/test_release_version.py b/python/sdk/tests/test_release_version.py index cf5a6cf57b..c66de01a3e 100644 --- a/python/sdk/tests/test_release_version.py +++ b/python/sdk/tests/test_release_version.py @@ -32,13 +32,31 @@ 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" diff --git a/scripts/build-python-release.py b/scripts/build-python-release.py index c9ee5e31c0..d326222efa 100644 --- a/scripts/build-python-release.py +++ b/scripts/build-python-release.py @@ -43,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): @@ -53,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_sdk-{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) @@ -76,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 From b64c3ac1ba37a8afe20de534ab666e85ae69377b Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 03:04:47 +0800 Subject: [PATCH 055/113] release(dsh): 0.0.1-rc.1 --- apps/cli/package.json | 2 +- apps/web/package.json | 2 +- package.json | 2 +- packages/acp/acp/package.json | 2 +- packages/api/gateway/package.json | 2 +- packages/api/remotes/package.json | 2 +- packages/attachment/attachment-local/package.json | 2 +- packages/attachment/attachment/package.json | 2 +- packages/bash/bash-env/package.json | 2 +- packages/bash/bash-local/package.json | 2 +- packages/bash/bash-sandbox/package.json | 2 +- packages/bash/bash/package.json | 2 +- packages/bash/pwsh-local/package.json | 2 +- packages/bash/pwsh-sandbox/package.json | 2 +- packages/bash/tool-bash/package.json | 2 +- packages/bash/tool-pwsh/package.json | 2 +- packages/boot/app-boot/package.json | 2 +- packages/boot/cmdline/package.json | 2 +- packages/bundle/base/package.json | 2 +- packages/bundle/headless/package.json | 2 +- packages/bundle/web-app/package.json | 2 +- packages/client/connection/package.json | 2 +- packages/client/hmr/package.json | 2 +- packages/client/locale/package.json | 2 +- packages/client/modules/package.json | 2 +- packages/client/runtime/package.json | 2 +- packages/client/schema-form/package.json | 2 +- packages/client/test-runtime/package.json | 2 +- packages/client/ui-agent-preset/package.json | 2 +- packages/client/ui-command/package.json | 2 +- packages/client/ui-conversation/package.json | 2 +- packages/client/ui-deliverables/package.json | 2 +- packages/client/ui-goal/package.json | 2 +- packages/client/ui-layout/package.json | 2 +- packages/client/ui-model/package.json | 2 +- packages/client/ui-models/package.json | 2 +- packages/client/ui-permission/package.json | 2 +- packages/client/ui-plan/package.json | 2 +- packages/client/ui-primitives/package.json | 2 +- packages/client/ui-question/package.json | 2 +- packages/client/ui-settings-general/package.json | 2 +- packages/client/ui-settings/package.json | 2 +- packages/client/ui-sidebar/package.json | 2 +- packages/client/ui-skill/package.json | 2 +- packages/client/ui-slash/package.json | 2 +- packages/client/ui-slots/package.json | 2 +- packages/client/ui-subagent/package.json | 2 +- packages/client/ui-theme/package.json | 2 +- packages/client/ui-tool/package.json | 2 +- packages/client/ui-trajectory/package.json | 2 +- packages/client/ui-workspace/package.json | 2 +- packages/client/web-react/package.json | 2 +- packages/client/web/package.json | 2 +- packages/code-runtime/code-runtime-worker/package.json | 2 +- packages/code-runtime/code-runtime/package.json | 2 +- packages/compact/command-compact/package.json | 2 +- packages/compact/compact-basic/package.json | 2 +- packages/compact/compact-tool-result-prune/package.json | 2 +- packages/compact/compact/package.json | 2 +- packages/context/session-reference/package.json | 2 +- packages/context/time-context/package.json | 2 +- packages/context/tmux-context/package.json | 2 +- packages/context/workspace-context/package.json | 2 +- packages/core/agent-default-model/package.json | 2 +- packages/core/agent-loop/package.json | 2 +- packages/core/agent-tool-mode/package.json | 2 +- packages/core/agent/package.json | 2 +- packages/core/scope/package.json | 2 +- packages/core/session/package.json | 2 +- packages/core/system-prompt/package.json | 2 +- packages/core/tools/package.json | 2 +- packages/credentials/credentials-local/package.json | 2 +- packages/credentials/credentials/package.json | 2 +- packages/e2b/e2b/package.json | 2 +- packages/e2b/fs-e2b/package.json | 2 +- packages/e2b/subprocess-e2b/package.json | 2 +- packages/examples/acp-demo/package.json | 2 +- packages/examples/agent-spine-demo/package.json | 2 +- packages/examples/jsonrpc-demo/package.json | 2 +- packages/feedback/command-feedback/package.json | 2 +- packages/fs/fs-local/package.json | 2 +- packages/fs/fs-policy/package.json | 2 +- packages/fs/fs-sandbox/package.json | 2 +- packages/fs/fs/package.json | 2 +- packages/fs/tool-fs-search/package.json | 2 +- packages/fs/tool-fs/package.json | 2 +- packages/fs/tool-str-replace-editor/package.json | 2 +- packages/goal/command-goal/package.json | 2 +- packages/goal/goal-session/package.json | 2 +- packages/goal/goal/package.json | 2 +- packages/goal/tool-goal/package.json | 2 +- packages/guard/repeat-tool-guard/package.json | 2 +- packages/guard/timeout-policy/package.json | 2 +- packages/hooks/hook-protocol/package.json | 2 +- packages/hooks/hooks-claude/package.json | 2 +- packages/hooks/hooks-codex/package.json | 2 +- packages/host/apiproxy/package.json | 2 +- packages/host/directory-picker-auto/package.json | 2 +- packages/host/directory-picker-browse/package.json | 2 +- packages/host/directory-picker-native/package.json | 2 +- packages/host/directory-picker/package.json | 2 +- packages/host/frontend-static/package.json | 2 +- packages/host/webserver/package.json | 2 +- packages/interaction/commands/package.json | 2 +- packages/interaction/permission/package.json | 2 +- packages/interaction/tool-ask-user/package.json | 2 +- packages/interaction/user-approval/package.json | 2 +- packages/interaction/user-interaction/package.json | 2 +- packages/llm/llm-deepseek/package.json | 2 +- packages/llm/llm-pi-ai/package.json | 2 +- packages/llm/llm-retry/package.json | 2 +- packages/llm/llm/package.json | 2 +- packages/llm/token-meter/package.json | 2 +- packages/lsp/lsp-local/package.json | 2 +- packages/lsp/lsp/package.json | 2 +- packages/lsp/tool-lsp/package.json | 2 +- packages/mcp/mcp-client/package.json | 2 +- packages/plan/plan-mode/package.json | 2 +- packages/preset/agent-presets/package.json | 2 +- packages/preset/persona/package.json | 2 +- packages/pty/pty-local/package.json | 2 +- packages/pty/pty/package.json | 2 +- packages/pty/tool-bash-persistent/package.json | 2 +- packages/pty/tool-pty/package.json | 2 +- packages/sandbox/sandbox-local/package.json | 2 +- packages/sandbox/sandbox-policy/package.json | 2 +- packages/sandbox/sandbox-windows-acl/package.json | 2 +- packages/sandbox/sandbox/package.json | 2 +- packages/scaffold/client/package.json | 2 +- packages/scaffold/create-sdk/package.json | 2 +- packages/scaffold/helper/package.json | 2 +- packages/scaffold/protocol/package.json | 2 +- packages/scaffold/scripts/package.json | 2 +- packages/scaffold/server/package.json | 2 +- packages/scaffold/telemetry/package.json | 2 +- packages/self-modification/tool-cordis/package.json | 2 +- packages/session-query/session-query-sqlite/package.json | 2 +- packages/session-query/session-query/package.json | 2 +- packages/session-query/tool-session-query/package.json | 2 +- packages/session/session-checkpoint-policy/package.json | 2 +- packages/session/session-persistence-jsonl/package.json | 2 +- packages/session/session-persistence-sqlite/package.json | 2 +- packages/session/session-persistence/package.json | 2 +- packages/session/session-projection-cache/package.json | 2 +- packages/session/session-projection/package.json | 2 +- packages/session/session-telemetry-otel/package.json | 2 +- packages/session/session-telemetry/package.json | 2 +- packages/session/session-title-all-messages-llm/package.json | 2 +- packages/session/session-title-first-message-llm/package.json | 2 +- packages/session/session-title-llm/package.json | 2 +- packages/session/session-title/package.json | 2 +- packages/session/user-id/package.json | 2 +- packages/settings/settings-local/package.json | 2 +- packages/settings/settings/package.json | 2 +- packages/skill/skill-badge/package.json | 2 +- packages/skill/skill-local/package.json | 2 +- packages/skill/skill/package.json | 2 +- packages/skill/tool-skill/package.json | 2 +- packages/spill/spill-local/package.json | 2 +- packages/spill/spill-policy/package.json | 2 +- packages/spill/spill/package.json | 2 +- packages/storage/storage-domain/package.json | 2 +- packages/storage/storage-json/package.json | 2 +- packages/storage/storage-sqlite/package.json | 2 +- packages/storage/storage/package.json | 2 +- packages/subagent/subagent-acp/package.json | 2 +- packages/subagent/subagent-claude-code/package.json | 2 +- packages/subagent/subagent-codex/package.json | 2 +- packages/subagent/subagent-dsh-sdk/package.json | 2 +- packages/subagent/subagent-fork/package.json | 2 +- packages/subagent/subagent-inprocess/package.json | 2 +- packages/subagent/subagent-spawn/package.json | 2 +- packages/subagent/subagent/package.json | 2 +- packages/subagent/tool-subagent-control/package.json | 2 +- packages/subagent/tool-subagent-report/package.json | 2 +- packages/subagent/tool-subagent/package.json | 2 +- packages/subprocess/subprocess-local/package.json | 2 +- packages/subprocess/subprocess/package.json | 2 +- packages/support/acp-snapshot/package.json | 2 +- packages/support/agent-loop-testkit/package.json | 2 +- packages/support/invariants/package.json | 2 +- packages/support/llm-mock-server/package.json | 2 +- packages/support/llm-replay/package.json | 2 +- packages/support/loader-smoke/package.json | 2 +- packages/tasks/tasks-local/package.json | 2 +- packages/tasks/tasks/package.json | 2 +- packages/tasks/tool-tasks/package.json | 2 +- packages/todo/tool-todo/package.json | 2 +- packages/typert/generator/package.json | 2 +- packages/typert/loader/package.json | 2 +- packages/typert/registry/package.json | 2 +- packages/typert/type-meta/package.json | 2 +- packages/util/atomic-write/package.json | 2 +- packages/util/brand/package.json | 2 +- packages/util/environment/package.json | 2 +- packages/util/native-command/package.json | 2 +- packages/util/paths/package.json | 2 +- packages/util/retention/package.json | 2 +- packages/util/timeout/package.json | 2 +- packages/web/tool-web/package.json | 2 +- packages/web/web-fetch-local/package.json | 2 +- packages/web/web-search-deepseek/package.json | 2 +- packages/web/web-search-exa/package.json | 2 +- packages/web/web-search-perplexity/package.json | 2 +- packages/web/web/package.json | 2 +- packages/workflow/tool-ralph/package.json | 2 +- packages/workflow/tool-workflow/package.json | 2 +- packages/workflow/workflow-workerthread/package.json | 2 +- packages/workflow/workflow/package.json | 2 +- packages/workspace/workspace/package.json | 2 +- 210 files changed, 210 insertions(+), 210 deletions(-) diff --git a/apps/cli/package.json b/apps/cli/package.json index b8f0887ac6..a294813086 100644 --- a/apps/cli/package.json +++ b/apps/cli/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh", "description": "dsh CLI: profile boot, plugin management, and the browser UI alias", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/apps/web/package.json b/apps/web/package.json index 9e71181b4b..828dffbe70 100644 --- a/apps/web/package.json +++ b/apps/web/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/package.json b/package.json index 277bd7fe85..d99acadf7a 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", diff --git a/packages/acp/acp/package.json b/packages/acp/acp/package.json index 5293a94e5d..b244c0367d 100644 --- a/packages/acp/acp/package.json +++ b/packages/acp/acp/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/api/gateway/package.json b/packages/api/gateway/package.json index bd90451fdc..48615dc55f 100644 --- a/packages/api/gateway/package.json +++ b/packages/api/gateway/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-gateway", "description": "TypeRT Remote Host dispatcher and Client API endpoint", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/api/remotes/package.json b/packages/api/remotes/package.json index 0a96fb2239..241a86ff9f 100644 --- a/packages/api/remotes/package.json +++ b/packages/api/remotes/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-api-remotes", "description": "Remote BFF assembly and Host Agent/Session lookup policy", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/attachment/attachment-local/package.json b/packages/attachment/attachment-local/package.json index c8804218f1..3bbf361032 100644 --- a/packages/attachment/attachment-local/package.json +++ b/packages/attachment/attachment-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment-local", "description": "Private content-addressed DSH_HOME attachment storage", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/attachment/attachment/package.json b/packages/attachment/attachment/package.json index 9d58dccdf6..1fa800d2bf 100644 --- a/packages/attachment/attachment/package.json +++ b/packages/attachment/attachment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-attachment", "description": "Durable immutable attachment storage seam for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/bash/bash-env/package.json b/packages/bash/bash-env/package.json index cde53b2bd1..acde7f422f 100644 --- a/packages/bash/bash-env/package.json +++ b/packages/bash/bash-env/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-env", "description": "Tool-independent managed DSH_* shell environment registry", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/bash/bash-local/package.json b/packages/bash/bash-local/package.json index 85eb6f2655..a46c5b6502 100644 --- a/packages/bash/bash-local/package.json +++ b/packages/bash/bash-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash-local", "description": "Local-subprocess implementation of the DeepSeek Harness bash executor seam", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/bash/bash-sandbox/package.json b/packages/bash/bash-sandbox/package.json index e77dba32e5..293748a67e 100644 --- a/packages/bash/bash-sandbox/package.json +++ b/packages/bash/bash-sandbox/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/bash/bash/package.json b/packages/bash/bash/package.json index 8d22d657fe..0821a526c8 100644 --- a/packages/bash/bash/package.json +++ b/packages/bash/bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-bash", "description": "Abstract bash executor seam (ctx.bash) for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/bash/pwsh-local/package.json b/packages/bash/pwsh-local/package.json index 5e58a5cd3f..59e8714608 100644 --- a/packages/bash/pwsh-local/package.json +++ b/packages/bash/pwsh-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pwsh-local", "description": "Local PowerShell implementation of the DeepSeek Harness bash executor seam", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/bash/pwsh-sandbox/package.json b/packages/bash/pwsh-sandbox/package.json index 12669329c4..53e418f401 100644 --- a/packages/bash/pwsh-sandbox/package.json +++ b/packages/bash/pwsh-sandbox/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/bash/tool-bash/package.json b/packages/bash/tool-bash/package.json index a3d878e8e2..9da6eb65ca 100644 --- a/packages/bash/tool-bash/package.json +++ b/packages/bash/tool-bash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-bash", "description": "Model-facing bash tool with optional generic background-task and sandbox-escalation support", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/bash/tool-pwsh/package.json b/packages/bash/tool-pwsh/package.json index 5188891aea..0b93e820c2 100644 --- a/packages/bash/tool-pwsh/package.json +++ b/packages/bash/tool-pwsh/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-pwsh", "description": "Model-facing pwsh tool over the bash executor seam", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/boot/app-boot/package.json b/packages/boot/app-boot/package.json index 4139847364..f6d4cfc07d 100644 --- a/packages/boot/app-boot/package.json +++ b/packages/boot/app-boot/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/boot/cmdline/package.json b/packages/boot/cmdline/package.json index 131c254af0..9b30a9f7c5 100644 --- a/packages/boot/cmdline/package.json +++ b/packages/boot/cmdline/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/bundle/base/package.json b/packages/bundle/base/package.json index 9c5fc2a5a8..655de66ec0 100644 --- a/packages/bundle/base/package.json +++ b/packages/bundle/base/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/bundle/headless/package.json b/packages/bundle/headless/package.json index 7778b7be8b..af0935d2a4 100644 --- a/packages/bundle/headless/package.json +++ b/packages/bundle/headless/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/bundle/web-app/package.json b/packages/bundle/web-app/package.json index cde4fa1013..181bb269c4 100644 --- a/packages/bundle/web-app/package.json +++ b/packages/bundle/web-app/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/connection/package.json b/packages/client/connection/package.json index 1b9481db93..025b5ffb90 100644 --- a/packages/client/connection/package.json +++ b/packages/client/connection/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/hmr/package.json b/packages/client/hmr/package.json index 3dad86d07a..6e7614bc27 100644 --- a/packages/client/hmr/package.json +++ b/packages/client/hmr/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/locale/package.json b/packages/client/locale/package.json index d44e8ee529..6415c26961 100644 --- a/packages/client/locale/package.json +++ b/packages/client/locale/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/modules/package.json b/packages/client/modules/package.json index e93cd2951d..3d97f98fa6 100644 --- a/packages/client/modules/package.json +++ b/packages/client/modules/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/runtime/package.json b/packages/client/runtime/package.json index 87e259f64c..749e564ef5 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-runtime", "description": "Client core services: SlotsService, SessionsService (scope tree + object layer)", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/schema-form/package.json b/packages/client/schema-form/package.json index 35d80feba6..90f59b329a 100644 --- a/packages/client/schema-form/package.json +++ b/packages/client/schema-form/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/test-runtime/package.json b/packages/client/test-runtime/package.json index 54088ded59..96f3add186 100644 --- a/packages/client/test-runtime/package.json +++ b/packages/client/test-runtime/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-agent-preset/package.json b/packages/client/ui-agent-preset/package.json index 77133dac21..e5d4e27790 100644 --- a/packages/client/ui-agent-preset/package.json +++ b/packages/client/ui-agent-preset/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-command/package.json b/packages/client/ui-command/package.json index d6b015eb96..93c0cf3910 100644 --- a/packages/client/ui-command/package.json +++ b/packages/client/ui-command/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-conversation/package.json b/packages/client/ui-conversation/package.json index f3dd749dde..e80432f79f 100644 --- a/packages/client/ui-conversation/package.json +++ b/packages/client/ui-conversation/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-deliverables/package.json b/packages/client/ui-deliverables/package.json index a5a82a833f..71eb96cf02 100644 --- a/packages/client/ui-deliverables/package.json +++ b/packages/client/ui-deliverables/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-deliverables", "description": "Produced-files turn tail: the deliverables row a finished turn ends with", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-goal/package.json b/packages/client/ui-goal/package.json index d1ed34e43c..9216af858a 100644 --- a/packages/client/ui-goal/package.json +++ b/packages/client/ui-goal/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-layout/package.json b/packages/client/ui-layout/package.json index 879100dbe6..232c4606ab 100644 --- a/packages/client/ui-layout/package.json +++ b/packages/client/ui-layout/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-model/package.json b/packages/client/ui-model/package.json index b8f411cdc7..069fb47c8e 100644 --- a/packages/client/ui-model/package.json +++ b/packages/client/ui-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-model", "description": "Model selection: the /model popupSelect over session.models / session.selectModel", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-models/package.json b/packages/client/ui-models/package.json index 0a7b6bbae8..af55b2a5cc 100644 --- a/packages/client/ui-models/package.json +++ b/packages/client/ui-models/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-permission/package.json b/packages/client/ui-permission/package.json index 880666ecad..41c3c7eb30 100644 --- a/packages/client/ui-permission/package.json +++ b/packages/client/ui-permission/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-plan/package.json b/packages/client/ui-plan/package.json index 52d8e9fffd..3ee6860ce5 100644 --- a/packages/client/ui-plan/package.json +++ b/packages/client/ui-plan/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-primitives/package.json b/packages/client/ui-primitives/package.json index 732321b932..05dbe8f822 100644 --- a/packages/client/ui-primitives/package.json +++ b/packages/client/ui-primitives/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-question/package.json b/packages/client/ui-question/package.json index 3153f8fd69..aafb3bf534 100644 --- a/packages/client/ui-question/package.json +++ b/packages/client/ui-question/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-settings-general/package.json b/packages/client/ui-settings-general/package.json index 8eee8eab62..e720ad810a 100644 --- a/packages/client/ui-settings-general/package.json +++ b/packages/client/ui-settings-general/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-settings/package.json b/packages/client/ui-settings/package.json index 37c1e1607c..3447573398 100644 --- a/packages/client/ui-settings/package.json +++ b/packages/client/ui-settings/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-sidebar/package.json b/packages/client/ui-sidebar/package.json index bffbbfd54c..73ce0817d7 100644 --- a/packages/client/ui-sidebar/package.json +++ b/packages/client/ui-sidebar/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-sidebar", "description": "Sidebar plugin: session multi-level tree, search, grouping, state dots", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-skill/package.json b/packages/client/ui-skill/package.json index c1ee7720bb..5734767d7b 100644 --- a/packages/client/ui-skill/package.json +++ b/packages/client/ui-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-skill", "description": "Web skill references and the dedicated skill tool row", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-slash/package.json b/packages/client/ui-slash/package.json index 00b5bd1f9a..485052ae5f 100644 --- a/packages/client/ui-slash/package.json +++ b/packages/client/ui-slash/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-slash", "description": "Input trigger pipeline: '/' and '@' detection, candidate menu, pick routing to registered sources", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-slots/package.json b/packages/client/ui-slots/package.json index 54b3d64925..85a8886519 100644 --- a/packages/client/ui-slots/package.json +++ b/packages/client/ui-slots/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-subagent/package.json b/packages/client/ui-subagent/package.json index 997bfadcf6..75e5b53af7 100644 --- a/packages/client/ui-subagent/package.json +++ b/packages/client/ui-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-subagent", "description": "Subagent conversation catalog, continuation routing UI, and '@' reference source", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-theme/package.json b/packages/client/ui-theme/package.json index 8d22586ffd..99538ed6ec 100644 --- a/packages/client/ui-theme/package.json +++ b/packages/client/ui-theme/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-tool/package.json b/packages/client/ui-tool/package.json index c0465db10c..6644d1d506 100644 --- a/packages/client/ui-tool/package.json +++ b/packages/client/ui-tool/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-ui-tool", "description": "Client Tool call-tree renderer and keyed per-tool presentation slot", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-trajectory/package.json b/packages/client/ui-trajectory/package.json index c50633c2c1..c11b607939 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/ui-workspace/package.json b/packages/client/ui-workspace/package.json index 206e037bc1..f19c44ea57 100644 --- a/packages/client/ui-workspace/package.json +++ b/packages/client/ui-workspace/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/web-react/package.json b/packages/client/web-react/package.json index 1fe9231134..16bab468df 100644 --- a/packages/client/web-react/package.json +++ b/packages/client/web-react/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-client-web-react", "description": "Shell-side React glue: createSlotRenderer, SessionProvider, bindSnapshotSelector (uSES bridge), useInvoke", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/client/web/package.json b/packages/client/web/package.json index eff725c83d..df5192e070 100644 --- a/packages/client/web/package.json +++ b/packages/client/web/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/code-runtime/code-runtime-worker/package.json b/packages/code-runtime/code-runtime-worker/package.json index bacaca170a..2614be990a 100644 --- a/packages/code-runtime/code-runtime-worker/package.json +++ b/packages/code-runtime/code-runtime-worker/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime-worker", "description": "Worker-thread implementation of the DeepSeek Harness code-execution seam", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/code-runtime/code-runtime/package.json b/packages/code-runtime/code-runtime/package.json index 89db4e6d88..8d4e77136b 100644 --- a/packages/code-runtime/code-runtime/package.json +++ b/packages/code-runtime/code-runtime/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-code-runtime", "description": "Abstract code-execution seam (ctx.codeRuntime) for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/compact/command-compact/package.json b/packages/compact/command-compact/package.json index 3b36536a34..83df4eae98 100644 --- a/packages/compact/command-compact/package.json +++ b/packages/compact/command-compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-compact", "description": "Human-facing slash command for explicit session compaction", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/compact/compact-basic/package.json b/packages/compact/compact-basic/package.json index 941e3aa32d..75e0dcd959 100644 --- a/packages/compact/compact-basic/package.json +++ b/packages/compact/compact-basic/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compact-basic", "description": "Token-meter-driven compaction policy and LLM summarization backend for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/compact/compact-tool-result-prune/package.json b/packages/compact/compact-tool-result-prune/package.json index 21951f5750..26f216a4dd 100644 --- a/packages/compact/compact-tool-result-prune/package.json +++ b/packages/compact/compact-tool-result-prune/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/compact/compact/package.json b/packages/compact/compact/package.json index 18879ad5b5..6f484bf8f6 100644 --- a/packages/compact/compact/package.json +++ b/packages/compact/compact/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-compact", "description": "Abstract compaction service seam (ctx.compact) for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/context/session-reference/package.json b/packages/context/session-reference/package.json index 4c7c7af3f8..c9dcb6af03 100644 --- a/packages/context/session-reference/package.json +++ b/packages/context/session-reference/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-reference", "description": "Cross-session snapshot references and durable untrusted model context (ctx.sessionReferences)", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/context/time-context/package.json b/packages/context/time-context/package.json index 6956bf48e0..c1c222bd6f 100644 --- a/packages/context/time-context/package.json +++ b/packages/context/time-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-time-context", "description": "Opt-in durable per-step context with the current time and elapsed time", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/context/tmux-context/package.json b/packages/context/tmux-context/package.json index 241661250f..6a4d7b81d8 100644 --- a/packages/context/tmux-context/package.json +++ b/packages/context/tmux-context/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/context/workspace-context/package.json b/packages/context/workspace-context/package.json index 236009f90d..9181b2aded 100644 --- a/packages/context/workspace-context/package.json +++ b/packages/context/workspace-context/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workspace-context", "description": "Workspace context loader for AGENTS.md/CLAUDE.md instruction files", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/agent-default-model/package.json b/packages/core/agent-default-model/package.json index 8826a9fcc4..6e3f6c0413 100644 --- a/packages/core/agent-default-model/package.json +++ b/packages/core/agent-default-model/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-default-model", "description": "Default model selection shared by Agent entry points", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/agent-loop/package.json b/packages/core/agent-loop/package.json index 9f37505853..038822967a 100644 --- a/packages/core/agent-loop/package.json +++ b/packages/core/agent-loop/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop", "description": "The concrete agent loop plugin for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/agent-tool-mode/package.json b/packages/core/agent-tool-mode/package.json index a47dbd1434..e0d35abf0f 100644 --- a/packages/core/agent-tool-mode/package.json +++ b/packages/core/agent-tool-mode/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/agent/package.json b/packages/core/agent/package.json index b4fe30f2e5..6c85552224 100644 --- a/packages/core/agent/package.json +++ b/packages/core/agent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent", "description": "Agent interface, registry, initiator scope, and event vocabulary for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/scope/package.json b/packages/core/scope/package.json index 53bd185aa5..d0a67e8841 100644 --- a/packages/core/scope/package.json +++ b/packages/core/scope/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scope", "description": "Scoped-context registration primitive (scope tags, scope-filtered event dispatch) for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/session/package.json b/packages/core/session/package.json index 40508e9a64..3528334b3c 100644 --- a/packages/core/session/package.json +++ b/packages/core/session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session", "description": "Event-sourced session store for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/system-prompt/package.json b/packages/core/system-prompt/package.json index e219dfa37a..1f411b554f 100644 --- a/packages/core/system-prompt/package.json +++ b/packages/core/system-prompt/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-system-prompt", "description": "System prompt assembly registry for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/core/tools/package.json b/packages/core/tools/package.json index e02c4afaef..6634098f99 100644 --- a/packages/core/tools/package.json +++ b/packages/core/tools/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tools", "description": "Tool registry and execution pipeline for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/credentials/credentials-local/package.json b/packages/credentials/credentials-local/package.json index 004c5caec4..977c51013c 100644 --- a/packages/credentials/credentials-local/package.json +++ b/packages/credentials/credentials-local/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/credentials/credentials/package.json b/packages/credentials/credentials/package.json index 8cc0d61e40..68c6be2f1b 100644 --- a/packages/credentials/credentials/package.json +++ b/packages/credentials/credentials/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-credentials", "description": "Abstract credential seam (ctx.credentials): settings carry references to secrets, providers own the values", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/e2b/e2b/package.json b/packages/e2b/e2b/package.json index fb47ba6c83..dafbb57b00 100644 --- a/packages/e2b/e2b/package.json +++ b/packages/e2b/e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-e2b", "description": "Shared E2B sandbox lifecycle for DeepSeek Harness provider adapters", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/e2b/fs-e2b/package.json b/packages/e2b/fs-e2b/package.json index 47c026eaf2..1f6e085e5f 100644 --- a/packages/e2b/fs-e2b/package.json +++ b/packages/e2b/fs-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-e2b", "description": "E2B filesystem implementation for DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/e2b/subprocess-e2b/package.json b/packages/e2b/subprocess-e2b/package.json index b7be6db8be..dfbdf73da5 100644 --- a/packages/e2b/subprocess-e2b/package.json +++ b/packages/e2b/subprocess-e2b/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-e2b", "description": "E2B subprocess implementation for DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/examples/acp-demo/package.json b/packages/examples/acp-demo/package.json index 1c7763482c..1d35f2b23f 100644 --- a/packages/examples/acp-demo/package.json +++ b/packages/examples/acp-demo/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/examples/agent-spine-demo/package.json b/packages/examples/agent-spine-demo/package.json index 3bfac5084c..2092965d75 100644 --- a/packages/examples/agent-spine-demo/package.json +++ b/packages/examples/agent-spine-demo/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/examples/jsonrpc-demo/package.json b/packages/examples/jsonrpc-demo/package.json index ac6b0f05ee..2524d75378 100644 --- a/packages/examples/jsonrpc-demo/package.json +++ b/packages/examples/jsonrpc-demo/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/feedback/command-feedback/package.json b/packages/feedback/command-feedback/package.json index afdba48641..b557eb788b 100644 --- a/packages/feedback/command-feedback/package.json +++ b/packages/feedback/command-feedback/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-feedback", "description": "Log-only session feedback producer and human-facing slash command", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/fs-local/package.json b/packages/fs/fs-local/package.json index c79a13a9d2..77ed82d979 100644 --- a/packages/fs/fs-local/package.json +++ b/packages/fs/fs-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-fs-local", "description": "Local-filesystem implementation of the DeepSeek Harness filesystem seam (ctx.fs)", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/fs-policy/package.json b/packages/fs/fs-policy/package.json index 39b6e0b6f0..a9e204ba90 100644 --- a/packages/fs/fs-policy/package.json +++ b/packages/fs/fs-policy/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/fs-sandbox/package.json b/packages/fs/fs-sandbox/package.json index 610724a528..cc6daed922 100644 --- a/packages/fs/fs-sandbox/package.json +++ b/packages/fs/fs-sandbox/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/fs/package.json b/packages/fs/fs/package.json index fa97204f47..e9779e9d96 100644 --- a/packages/fs/fs/package.json +++ b/packages/fs/fs/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/tool-fs-search/package.json b/packages/fs/tool-fs-search/package.json index 8a6a50f1b3..ca56b0198a 100644 --- a/packages/fs/tool-fs-search/package.json +++ b/packages/fs/tool-fs-search/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/tool-fs/package.json b/packages/fs/tool-fs/package.json index 1e4ef4fbf8..65ed3dcb1a 100644 --- a/packages/fs/tool-fs/package.json +++ b/packages/fs/tool-fs/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/fs/tool-str-replace-editor/package.json b/packages/fs/tool-str-replace-editor/package.json index 2a3b18bfdc..5b20d4afc4 100644 --- a/packages/fs/tool-str-replace-editor/package.json +++ b/packages/fs/tool-str-replace-editor/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/goal/command-goal/package.json b/packages/goal/command-goal/package.json index bac6e02048..b87f385599 100644 --- a/packages/goal/command-goal/package.json +++ b/packages/goal/command-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-command-goal", "description": "Human-facing slash command for persisted same-session goals", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/goal/goal-session/package.json b/packages/goal/goal-session/package.json index a43a4b1169..fa42ef63d3 100644 --- a/packages/goal/goal-session/package.json +++ b/packages/goal/goal-session/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal-session", "description": "Race-fenced same-session goal-round driver", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/goal/goal/package.json b/packages/goal/goal/package.json index 5ae59a8093..a2f90fced3 100644 --- a/packages/goal/goal/package.json +++ b/packages/goal/goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-goal", "description": "Event-sourced same-session goal state and lifecycle service for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/goal/tool-goal/package.json b/packages/goal/tool-goal/package.json index 309e020842..e83648ab97 100644 --- a/packages/goal/tool-goal/package.json +++ b/packages/goal/tool-goal/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-goal", "description": "Model-facing same-session goal tools with execution-time authority checks", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/guard/repeat-tool-guard/package.json b/packages/guard/repeat-tool-guard/package.json index fbb6a39192..27e7cc916e 100644 --- a/packages/guard/repeat-tool-guard/package.json +++ b/packages/guard/repeat-tool-guard/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/guard/timeout-policy/package.json b/packages/guard/timeout-policy/package.json index ab07cc70e0..4800c0f472 100644 --- a/packages/guard/timeout-policy/package.json +++ b/packages/guard/timeout-policy/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/hooks/hook-protocol/package.json b/packages/hooks/hook-protocol/package.json index 7d842222e0..ad92d9dd78 100644 --- a/packages/hooks/hook-protocol/package.json +++ b/packages/hooks/hook-protocol/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/hooks/hooks-claude/package.json b/packages/hooks/hooks-claude/package.json index a41e2e3b4d..9ed1ed7ceb 100644 --- a/packages/hooks/hooks-claude/package.json +++ b/packages/hooks/hooks-claude/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/hooks/hooks-codex/package.json b/packages/hooks/hooks-codex/package.json index 4ff3f3dc38..a2bd73fba1 100644 --- a/packages/hooks/hooks-codex/package.json +++ b/packages/hooks/hooks-codex/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/host/apiproxy/package.json b/packages/host/apiproxy/package.json index c6da4ab3ae..6ccec04164 100644 --- a/packages/host/apiproxy/package.json +++ b/packages/host/apiproxy/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/host/directory-picker-auto/package.json b/packages/host/directory-picker-auto/package.json index bea692eedc..4ad110d48f 100644 --- a/packages/host/directory-picker-auto/package.json +++ b/packages/host/directory-picker-auto/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/host/directory-picker-browse/package.json b/packages/host/directory-picker-browse/package.json index 224f73de29..3b42032e16 100644 --- a/packages/host/directory-picker-browse/package.json +++ b/packages/host/directory-picker-browse/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/host/directory-picker-native/package.json b/packages/host/directory-picker-native/package.json index acf9278c89..01817a238e 100644 --- a/packages/host/directory-picker-native/package.json +++ b/packages/host/directory-picker-native/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/host/directory-picker/package.json b/packages/host/directory-picker/package.json index afad024fa2..0540414965 100644 --- a/packages/host/directory-picker/package.json +++ b/packages/host/directory-picker/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/host/frontend-static/package.json b/packages/host/frontend-static/package.json index de7ceb9cf2..61c144abe9 100644 --- a/packages/host/frontend-static/package.json +++ b/packages/host/frontend-static/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/host/webserver/package.json b/packages/host/webserver/package.json index 48378f5ff9..016ae8f253 100644 --- a/packages/host/webserver/package.json +++ b/packages/host/webserver/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/interaction/commands/package.json b/packages/interaction/commands/package.json index 8e527f64be..870a901b99 100644 --- a/packages/interaction/commands/package.json +++ b/packages/interaction/commands/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-commands", "description": "Plugin-owned human command registry for DeepSeek Harness UI surfaces", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/interaction/permission/package.json b/packages/interaction/permission/package.json index 85c959315d..054773f691 100644 --- a/packages/interaction/permission/package.json +++ b/packages/interaction/permission/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/interaction/tool-ask-user/package.json b/packages/interaction/tool-ask-user/package.json index b0ffe378ee..dc025017e0 100644 --- a/packages/interaction/tool-ask-user/package.json +++ b/packages/interaction/tool-ask-user/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ask-user", "description": "Model-facing ask_user_question tool over the ctx.userInteraction seam", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/interaction/user-approval/package.json b/packages/interaction/user-approval/package.json index 892dcbcf87..37b29c4697 100644 --- a/packages/interaction/user-approval/package.json +++ b/packages/interaction/user-approval/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/interaction/user-interaction/package.json b/packages/interaction/user-interaction/package.json index cd0336ae5c..fe90d7534c 100644 --- a/packages/interaction/user-interaction/package.json +++ b/packages/interaction/user-interaction/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-interaction", "description": "Abstract user-interaction seam (ctx.userInteraction) for asking the human during agent runs", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/llm/llm-deepseek/package.json b/packages/llm/llm-deepseek/package.json index 8baa42f0c9..834e6c5387 100644 --- a/packages/llm/llm-deepseek/package.json +++ b/packages/llm/llm-deepseek/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-deepseek", "description": "DeepSeek chat-completions adapter for the DeepSeek Harness LLM seam", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/llm/llm-pi-ai/package.json b/packages/llm/llm-pi-ai/package.json index 5427a26277..3d36802a3e 100644 --- a/packages/llm/llm-pi-ai/package.json +++ b/packages/llm/llm-pi-ai/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/llm/llm-retry/package.json b/packages/llm/llm-retry/package.json index 556d42abf9..da8358c620 100644 --- a/packages/llm/llm-retry/package.json +++ b/packages/llm/llm-retry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-retry", "description": "Provider-routed LLM request retry policy for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/llm/llm/package.json b/packages/llm/llm/package.json index 9341670dd1..523ab16cb3 100644 --- a/packages/llm/llm/package.json +++ b/packages/llm/llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm", "description": "Provider-neutral LLM service interface for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/llm/token-meter/package.json b/packages/llm/token-meter/package.json index 0755e6842a..ef08734fe2 100644 --- a/packages/llm/token-meter/package.json +++ b/packages/llm/token-meter/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-token-meter", "description": "Replay-aware token measurement service (ctx.tokenMeter) for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/lsp/lsp-local/package.json b/packages/lsp/lsp-local/package.json index 9cdc20a6c7..f31e48080f 100644 --- a/packages/lsp/lsp-local/package.json +++ b/packages/lsp/lsp-local/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/lsp/lsp/package.json b/packages/lsp/lsp/package.json index 41b5f12e42..6395749c68 100644 --- a/packages/lsp/lsp/package.json +++ b/packages/lsp/lsp/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/lsp/tool-lsp/package.json b/packages/lsp/tool-lsp/package.json index ab5b9603e8..d4003a61e5 100644 --- a/packages/lsp/tool-lsp/package.json +++ b/packages/lsp/tool-lsp/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/mcp/mcp-client/package.json b/packages/mcp/mcp-client/package.json index 1a12f7c674..99b3474c6e 100644 --- a/packages/mcp/mcp-client/package.json +++ b/packages/mcp/mcp-client/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/plan/plan-mode/package.json b/packages/plan/plan-mode/package.json index c8c228e00a..33b536d3ee 100644 --- a/packages/plan/plan-mode/package.json +++ b/packages/plan/plan-mode/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/preset/agent-presets/package.json b/packages/preset/agent-presets/package.json index c5fc5eb165..5a3bd3e913 100644 --- a/packages/preset/agent-presets/package.json +++ b/packages/preset/agent-presets/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-presets", "description": "Per-session agent composition from preset cordis.yml files for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/preset/persona/package.json b/packages/preset/persona/package.json index db3121cdaa..7a9c4cd746 100644 --- a/packages/preset/persona/package.json +++ b/packages/preset/persona/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-persona", "description": "Composition-authored deployment persona section for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/pty/pty-local/package.json b/packages/pty/pty-local/package.json index f7f84504bd..3db9e83889 100644 --- a/packages/pty/pty-local/package.json +++ b/packages/pty/pty-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-pty-local", "description": "Persistent shell PTY backend over the DeepSeek Harness subprocess terminal primitive", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/pty/pty/package.json b/packages/pty/pty/package.json index ad874ee876..37837181fb 100644 --- a/packages/pty/pty/package.json +++ b/packages/pty/pty/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/pty/tool-bash-persistent/package.json b/packages/pty/tool-bash-persistent/package.json index 25f248e047..0074f050b1 100644 --- a/packages/pty/tool-bash-persistent/package.json +++ b/packages/pty/tool-bash-persistent/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/pty/tool-pty/package.json b/packages/pty/tool-pty/package.json index c60acc3572..804f6fb1a4 100644 --- a/packages/pty/tool-pty/package.json +++ b/packages/pty/tool-pty/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/sandbox/sandbox-local/package.json b/packages/sandbox/sandbox-local/package.json index 52a2801b7b..64fdcb8991 100644 --- a/packages/sandbox/sandbox-local/package.json +++ b/packages/sandbox/sandbox-local/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/sandbox/sandbox-policy/package.json b/packages/sandbox/sandbox-policy/package.json index 9aaf77e59f..e5736878e3 100644 --- a/packages/sandbox/sandbox-policy/package.json +++ b/packages/sandbox/sandbox-policy/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/sandbox/sandbox-windows-acl/package.json b/packages/sandbox/sandbox-windows-acl/package.json index a59898ea86..0cbc1e9113 100644 --- a/packages/sandbox/sandbox-windows-acl/package.json +++ b/packages/sandbox/sandbox-windows-acl/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/sandbox/sandbox/package.json b/packages/sandbox/sandbox/package.json index 057920ce44..b25c8b74a0 100644 --- a/packages/sandbox/sandbox/package.json +++ b/packages/sandbox/sandbox/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/scaffold/client/package.json b/packages/scaffold/client/package.json index 41786be068..796a32d5c0 100644 --- a/packages/scaffold/client/package.json +++ b/packages/scaffold/client/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/scaffold/create-sdk/package.json b/packages/scaffold/create-sdk/package.json index 3f4395eed7..33cf2f2f15 100644 --- a/packages/scaffold/create-sdk/package.json +++ b/packages/scaffold/create-sdk/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/create-sdk", "description": "Create a DeepSeek Harness SDK project with npm create @deepseek-ai/sdk", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/scaffold/helper/package.json b/packages/scaffold/helper/package.json index d79661c0ea..74df5f76a8 100644 --- a/packages/scaffold/helper/package.json +++ b/packages/scaffold/helper/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-helper", "description": "Domain model and infrastructure for creating and editing DeepSeek Harness SDK projects", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/scaffold/protocol/package.json b/packages/scaffold/protocol/package.json index 7fc86b5fc3..42c326edf8 100644 --- a/packages/scaffold/protocol/package.json +++ b/packages/scaffold/protocol/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/scaffold/scripts/package.json b/packages/scaffold/scripts/package.json index 20fd286b50..c12396fccc 100644 --- a/packages/scaffold/scripts/package.json +++ b/packages/scaffold/scripts/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-scripts", "description": "DeepSeek Harness SDK launcher for start, dev, build, and project configuration", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/scaffold/server/package.json b/packages/scaffold/server/package.json index 237ce06ecc..0bd44281fe 100644 --- a/packages/scaffold/server/package.json +++ b/packages/scaffold/server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-jsonrpc", "description": "Stdio JSON-RPC server plugin for out-of-process DeepSeek Harness SDK clients", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/scaffold/telemetry/package.json b/packages/scaffold/telemetry/package.json index 50c501cd51..4dfeb54e2b 100644 --- a/packages/scaffold/telemetry/package.json +++ b/packages/scaffold/telemetry/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/self-modification/tool-cordis/package.json b/packages/self-modification/tool-cordis/package.json index ed0f466b62..66fc24d91a 100644 --- a/packages/self-modification/tool-cordis/package.json +++ b/packages/self-modification/tool-cordis/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session-query/session-query-sqlite/package.json b/packages/session-query/session-query-sqlite/package.json index 9a799bf167..156643a8bd 100644 --- a/packages/session-query/session-query-sqlite/package.json +++ b/packages/session-query/session-query-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query-sqlite", "description": "Concrete ctx.sessionQuery backend with SQLite FTS5 search", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session-query/session-query/package.json b/packages/session-query/session-query/package.json index 6f19563c7b..4d0e4c29de 100644 --- a/packages/session-query/session-query/package.json +++ b/packages/session-query/session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-query", "description": "Combined session query service contract with concrete reads, traces, and filters", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session-query/tool-session-query/package.json b/packages/session-query/tool-session-query/package.json index 71afb52687..6d8ef7696a 100644 --- a/packages/session-query/tool-session-query/package.json +++ b/packages/session-query/tool-session-query/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-session-query", "description": "Workspace-authorized model-facing session history search, trace, and event read tools", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-checkpoint-policy/package.json b/packages/session/session-checkpoint-policy/package.json index 1151cf1a9c..751b95d56a 100644 --- a/packages/session/session-checkpoint-policy/package.json +++ b/packages/session/session-checkpoint-policy/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-checkpoint-policy", "description": "Semantic session durability checkpoints before model requests and tool side effects", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-persistence-jsonl/package.json b/packages/session/session-persistence-jsonl/package.json index 38d6cfba97..aec127fa21 100644 --- a/packages/session/session-persistence-jsonl/package.json +++ b/packages/session/session-persistence-jsonl/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-jsonl", "description": "JSONL durable session persistence backend for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-persistence-sqlite/package.json b/packages/session/session-persistence-sqlite/package.json index f52fdc6bca..60858d5aa8 100644 --- a/packages/session/session-persistence-sqlite/package.json +++ b/packages/session/session-persistence-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence-sqlite", "description": "SQLite durable session persistence backend for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-persistence/package.json b/packages/session/session-persistence/package.json index 8430c7c91a..33c900a3d8 100644 --- a/packages/session/session-persistence/package.json +++ b/packages/session/session-persistence/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-persistence", "description": "Abstract durable session persistence seam (ctx.sessionPersistence) for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-projection-cache/package.json b/packages/session/session-projection-cache/package.json index cbc40eebe0..59c3cd2a57 100644 --- a/packages/session/session-projection-cache/package.json +++ b/packages/session/session-projection-cache/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-projection/package.json b/packages/session/session-projection/package.json index 4d7a438fae..fee23d3970 100644 --- a/packages/session/session-projection/package.json +++ b/packages/session/session-projection/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-telemetry-otel/package.json b/packages/session/session-telemetry-otel/package.json index 0d2a88bc6a..d5229618a8 100644 --- a/packages/session/session-telemetry-otel/package.json +++ b/packages/session/session-telemetry-otel/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-telemetry/package.json b/packages/session/session-telemetry/package.json index 613d1f6ac3..8dc9a7fb67 100644 --- a/packages/session/session-telemetry/package.json +++ b/packages/session/session-telemetry/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-title-all-messages-llm/package.json b/packages/session/session-title-all-messages-llm/package.json index c406342a9f..d333b5d5ca 100644 --- a/packages/session/session-title-all-messages-llm/package.json +++ b/packages/session/session-title-all-messages-llm/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-title-first-message-llm/package.json b/packages/session/session-title-first-message-llm/package.json index a75f220053..f259b41435 100644 --- a/packages/session/session-title-first-message-llm/package.json +++ b/packages/session/session-title-first-message-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-first-message-llm", "description": "First-message LLM provider plugin for DeepSeek Harness session titles", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-title-llm/package.json b/packages/session/session-title-llm/package.json index 062eca9259..dff4c3fde3 100644 --- a/packages/session/session-title-llm/package.json +++ b/packages/session/session-title-llm/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title-llm", "description": "Shared LLM generation policy for DeepSeek Harness session-title providers", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/session-title/package.json b/packages/session/session-title/package.json index e0239f185b..5ae395c008 100644 --- a/packages/session/session-title/package.json +++ b/packages/session/session-title/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-session-title", "description": "Log-backed session title service and provider registry for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/session/user-id/package.json b/packages/session/user-id/package.json index 36046b5b58..5ea2e7a5a9 100644 --- a/packages/session/user-id/package.json +++ b/packages/session/user-id/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-user-id", "description": "Shared anonymous user identity for DeepSeek Harness telemetry and feedback correlation", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/settings/settings-local/package.json b/packages/settings/settings-local/package.json index 0f5bca31f3..2ea49671fc 100644 --- a/packages/settings/settings-local/package.json +++ b/packages/settings/settings-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings-local", "description": "File-backed settings provider (settings.yaml) for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/settings/settings/package.json b/packages/settings/settings/package.json index 568a8fba58..9899ded2cc 100644 --- a/packages/settings/settings/package.json +++ b/packages/settings/settings/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-settings", "description": "Abstract user-settings seam (ctx.settings) for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/skill/skill-badge/package.json b/packages/skill/skill-badge/package.json index eb55678529..c3c79538c2 100644 --- a/packages/skill/skill-badge/package.json +++ b/packages/skill/skill-badge/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-badge", "description": "Bundled dsh badge skill provider for DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/skill/skill-local/package.json b/packages/skill/skill-local/package.json index 386da9386b..c9f3ba8f27 100644 --- a/packages/skill/skill-local/package.json +++ b/packages/skill/skill-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill-local", "description": "Local filesystem skill provider for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/skill/skill/package.json b/packages/skill/skill/package.json index b546ae9c60..dbb4429d9d 100644 --- a/packages/skill/skill/package.json +++ b/packages/skill/skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-skill", "description": "Agent skill provider registry for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/skill/tool-skill/package.json b/packages/skill/tool-skill/package.json index f1315b47cf..7712261549 100644 --- a/packages/skill/tool-skill/package.json +++ b/packages/skill/tool-skill/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-skill", "description": "Model-facing skill loading tool for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/spill/spill-local/package.json b/packages/spill/spill-local/package.json index 43f1bfcfeb..1bab4c650f 100644 --- a/packages/spill/spill-local/package.json +++ b/packages/spill/spill-local/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/spill/spill-policy/package.json b/packages/spill/spill-policy/package.json index 8236e9104a..cadec495fb 100644 --- a/packages/spill/spill-policy/package.json +++ b/packages/spill/spill-policy/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/spill/spill/package.json b/packages/spill/spill/package.json index 48a21f52c9..c3379e6f72 100644 --- a/packages/spill/spill/package.json +++ b/packages/spill/spill/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/storage/storage-domain/package.json b/packages/storage/storage-domain/package.json index 9c3acd2ba7..064ec4aa8a 100644 --- a/packages/storage/storage-domain/package.json +++ b/packages/storage/storage-domain/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/storage/storage-json/package.json b/packages/storage/storage-json/package.json index 86ec3dc5b8..78e1d9231a 100644 --- a/packages/storage/storage-json/package.json +++ b/packages/storage/storage-json/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-json", "description": "JSON file KV storage backend for the DeepSeek Harness storage hub", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/storage/storage-sqlite/package.json b/packages/storage/storage-sqlite/package.json index b30e80b5e7..2fef41cebd 100644 --- a/packages/storage/storage-sqlite/package.json +++ b/packages/storage/storage-sqlite/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-storage-sqlite", "description": "SQLite storage backend (kv facet) for the DeepSeek Harness storage hub", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/storage/storage/package.json b/packages/storage/storage/package.json index efe8294de3..39c8f0bed3 100644 --- a/packages/storage/storage/package.json +++ b/packages/storage/storage/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-acp/package.json b/packages/subagent/subagent-acp/package.json index 62a648e3d5..5efd65bcd5 100644 --- a/packages/subagent/subagent-acp/package.json +++ b/packages/subagent/subagent-acp/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-claude-code/package.json b/packages/subagent/subagent-claude-code/package.json index 22876aa0ca..4767d1ccb8 100644 --- a/packages/subagent/subagent-claude-code/package.json +++ b/packages/subagent/subagent-claude-code/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-claude-code", "description": "One-shot Claude Code subagent provider over the official Agent SDK", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-codex/package.json b/packages/subagent/subagent-codex/package.json index d22b5c517f..17e13287ef 100644 --- a/packages/subagent/subagent-codex/package.json +++ b/packages/subagent/subagent-codex/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-codex", "description": "One-shot Codex subagent provider over the official app-server protocol", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-dsh-sdk/package.json b/packages/subagent/subagent-dsh-sdk/package.json index d6cc3a3eef..5d15e3da45 100644 --- a/packages/subagent/subagent-dsh-sdk/package.json +++ b/packages/subagent/subagent-dsh-sdk/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-fork/package.json b/packages/subagent/subagent-fork/package.json index 08515ea47b..85ef8c6d88 100644 --- a/packages/subagent/subagent-fork/package.json +++ b/packages/subagent/subagent-fork/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-inprocess/package.json b/packages/subagent/subagent-inprocess/package.json index 8bf3d7a8ca..457bd16d36 100644 --- a/packages/subagent/subagent-inprocess/package.json +++ b/packages/subagent/subagent-inprocess/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent-spawn/package.json b/packages/subagent/subagent-spawn/package.json index a45bc78143..094470807b 100644 --- a/packages/subagent/subagent-spawn/package.json +++ b/packages/subagent/subagent-spawn/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent-spawn", "description": "In-process spawn subagent backend: runs a fresh child agent on ctx.agents", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/subagent/package.json b/packages/subagent/subagent/package.json index 4be1d4d947..8a1bbef19f 100644 --- a/packages/subagent/subagent/package.json +++ b/packages/subagent/subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subagent", "description": "Abstract subagent seam (ctx.subagents): named-provider registry for delegating to child agents", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/tool-subagent-control/package.json b/packages/subagent/tool-subagent-control/package.json index 0184a5af1a..1a4a1ad4ad 100644 --- a/packages/subagent/tool-subagent-control/package.json +++ b/packages/subagent/tool-subagent-control/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/tool-subagent-report/package.json b/packages/subagent/tool-subagent-report/package.json index 78adfef325..f9dcd90588 100644 --- a/packages/subagent/tool-subagent-report/package.json +++ b/packages/subagent/tool-subagent-report/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent-report", "description": "Child-scoped report tool over ctx.subagents continuations", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subagent/tool-subagent/package.json b/packages/subagent/tool-subagent/package.json index 6930f5dcc2..b4b912e7db 100644 --- a/packages/subagent/tool-subagent/package.json +++ b/packages/subagent/tool-subagent/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-subagent", "description": "Model-facing subagent delegation tool over the ctx.subagents seam", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subprocess/subprocess-local/package.json b/packages/subprocess/subprocess-local/package.json index d307a0742b..12f47c0400 100644 --- a/packages/subprocess/subprocess-local/package.json +++ b/packages/subprocess/subprocess-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-subprocess-local", "description": "Local-subprocess implementation of the DeepSeek Harness subprocess seam", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/subprocess/subprocess/package.json b/packages/subprocess/subprocess/package.json index 214f5f67a8..55558024f4 100644 --- a/packages/subprocess/subprocess/package.json +++ b/packages/subprocess/subprocess/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/support/acp-snapshot/package.json b/packages/support/acp-snapshot/package.json index 642565885f..e06a890806 100644 --- a/packages/support/acp-snapshot/package.json +++ b/packages/support/acp-snapshot/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/support/agent-loop-testkit/package.json b/packages/support/agent-loop-testkit/package.json index 0bde847ca9..43c49f3953 100644 --- a/packages/support/agent-loop-testkit/package.json +++ b/packages/support/agent-loop-testkit/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-agent-loop-testkit", "description": "Shared prerequisite mounting for tests that exercise the concrete agent loop", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/support/invariants/package.json b/packages/support/invariants/package.json index 122ab3dd9f..1d01c18bd0 100644 --- a/packages/support/invariants/package.json +++ b/packages/support/invariants/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-invariants", "description": "Registry service for package-owned DeepSeek Harness runtime invariants", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/support/llm-mock-server/package.json b/packages/support/llm-mock-server/package.json index 13456d14e7..203b092987 100644 --- a/packages/support/llm-mock-server/package.json +++ b/packages/support/llm-mock-server/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-llm-mock-server", "description": "Scriptable OpenAI-compatible HTTP/SSE fault server for LLM recovery tests", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/support/llm-replay/package.json b/packages/support/llm-replay/package.json index 326d13bb81..3c05e7fbf6 100644 --- a/packages/support/llm-replay/package.json +++ b/packages/support/llm-replay/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/support/loader-smoke/package.json b/packages/support/loader-smoke/package.json index 169bf2f548..9f43ca3a01 100644 --- a/packages/support/loader-smoke/package.json +++ b/packages/support/loader-smoke/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-loader-smoke", "description": "Shared subprocess and direct-agent harness for keyless real-Loader example smoke tests", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/tasks/tasks-local/package.json b/packages/tasks/tasks-local/package.json index b92cbfe03f..cd40629948 100644 --- a/packages/tasks/tasks-local/package.json +++ b/packages/tasks/tasks-local/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tasks-local", "description": "Process-local implementation of the DeepSeek Harness background task registry seam", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/tasks/tasks/package.json b/packages/tasks/tasks/package.json index 5bd6a7e3c1..cfc7fa70bf 100644 --- a/packages/tasks/tasks/package.json +++ b/packages/tasks/tasks/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/tasks/tool-tasks/package.json b/packages/tasks/tool-tasks/package.json index 69aed7e859..c1ab0be9c4 100644 --- a/packages/tasks/tool-tasks/package.json +++ b/packages/tasks/tool-tasks/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/todo/tool-todo/package.json b/packages/todo/tool-todo/package.json index d15d05bb10..0f080c5d33 100644 --- a/packages/todo/tool-todo/package.json +++ b/packages/todo/tool-todo/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-todo", "description": "Model-facing todo_write tool over the DeepSeek Harness event-sourced session log", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/typert/generator/package.json b/packages/typert/generator/package.json index f17ce63b8a..53e711b3a7 100644 --- a/packages/typert/generator/package.json +++ b/packages/typert/generator/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-generator", "description": "TypeScript project analyzer and model-driven Typert artifact generator", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/typert/loader/package.json b/packages/typert/loader/package.json index 7d1baa743a..ec66e2165b 100644 --- a/packages/typert/loader/package.json +++ b/packages/typert/loader/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-loader", "description": "Loader integration for generated Typert package contributions", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/typert/registry/package.json b/packages/typert/registry/package.json index 464415116b..0a75703929 100644 --- a/packages/typert/registry/package.json +++ b/packages/typert/registry/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-typert-registry", "description": "Runtime registry for generated package reflection and Zod schemas", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/typert/type-meta/package.json b/packages/typert/type-meta/package.json index e383ba0eec..3195392acb 100644 --- a/packages/typert/type-meta/package.json +++ b/packages/typert/type-meta/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-type-meta", "description": "Compiler-independent Remote metadata and TypeRT provider protocols", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/atomic-write/package.json b/packages/util/atomic-write/package.json index b514058816..a26a5e62d3 100644 --- a/packages/util/atomic-write/package.json +++ b/packages/util/atomic-write/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/brand/package.json b/packages/util/brand/package.json index 01f33fefe5..1496dfdb3a 100644 --- a/packages/util/brand/package.json +++ b/packages/util/brand/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-brand", "description": "Type-only Branded nominal-typing primitive for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/environment/package.json b/packages/util/environment/package.json index ddc2e40d23..6fa44ab20c 100644 --- a/packages/util/environment/package.json +++ b/packages/util/environment/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-environment", "description": "Immutable DeepSeek Harness launch environment that records which layer supplied each value", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/native-command/package.json b/packages/util/native-command/package.json index b3145ee41a..af0891fb82 100644 --- a/packages/util/native-command/package.json +++ b/packages/util/native-command/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/paths/package.json b/packages/util/paths/package.json index 04d692f56d..e3c492ea34 100644 --- a/packages/util/paths/package.json +++ b/packages/util/paths/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-paths", "description": "Shared filesystem path helpers for the DeepSeek Harness", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/retention/package.json b/packages/util/retention/package.json index 7e172bb131..eea04fee3e 100644 --- a/packages/util/retention/package.json +++ b/packages/util/retention/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/util/timeout/package.json b/packages/util/timeout/package.json index 194662100a..72daaa2c6b 100644 --- a/packages/util/timeout/package.json +++ b/packages/util/timeout/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-timeout", "description": "Zero-dependency timeout/deadline primitive: clampTimeout, deadline, timeoutOf, TimeoutReason (timing + classification only, no termination)", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/web/tool-web/package.json b/packages/web/tool-web/package.json index c5e1ea2b6b..0135e83403 100644 --- a/packages/web/tool-web/package.json +++ b/packages/web/tool-web/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/web/web-fetch-local/package.json b/packages/web/web-fetch-local/package.json index 148ecde3e0..562f8be8f6 100644 --- a/packages/web/web-fetch-local/package.json +++ b/packages/web/web-fetch-local/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/web/web-search-deepseek/package.json b/packages/web/web-search-deepseek/package.json index f3b29d20a4..c3d304678c 100644 --- a/packages/web/web-search-deepseek/package.json +++ b/packages/web/web-search-deepseek/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/web/web-search-exa/package.json b/packages/web/web-search-exa/package.json index da16ca3fce..55b18bec50 100644 --- a/packages/web/web-search-exa/package.json +++ b/packages/web/web-search-exa/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/web/web-search-perplexity/package.json b/packages/web/web-search-perplexity/package.json index 1d266f1d13..09c3b823ae 100644 --- a/packages/web/web-search-perplexity/package.json +++ b/packages/web/web-search-perplexity/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/web/web/package.json b/packages/web/web/package.json index d9cf4f5726..21e9811c1e 100644 --- a/packages/web/web/package.json +++ b/packages/web/web/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/workflow/tool-ralph/package.json b/packages/workflow/tool-ralph/package.json index 341508e3ed..f92eb944c4 100644 --- a/packages/workflow/tool-ralph/package.json +++ b/packages/workflow/tool-ralph/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-ralph", "description": "Model-facing fresh-agent Ralph loop over the workflow and subagent seams", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/workflow/tool-workflow/package.json b/packages/workflow/tool-workflow/package.json index d111d2797d..4d8bc7b924 100644 --- a/packages/workflow/tool-workflow/package.json +++ b/packages/workflow/tool-workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-tool-workflow", "description": "Model-facing workflow tool: run a JavaScript orchestration script over ctx.workflows", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/workflow/workflow-workerthread/package.json b/packages/workflow/workflow-workerthread/package.json index 53e03ebc2d..af20d74fb2 100644 --- a/packages/workflow/workflow-workerthread/package.json +++ b/packages/workflow/workflow-workerthread/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/workflow/workflow/package.json b/packages/workflow/workflow/package.json index 1c342bcebe..23976826bb 100644 --- a/packages/workflow/workflow/package.json +++ b/packages/workflow/workflow/package.json @@ -1,7 +1,7 @@ { "name": "@deepseek-ai/dsh-workflow", "description": "Workflow capability seam: ctx.workflows service, run vocabulary, and workflow/* events", - "version": "0.0.1", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, diff --git a/packages/workspace/workspace/package.json b/packages/workspace/workspace/package.json index 7efc53b6c8..4ab1aeecbe 100644 --- a/packages/workspace/workspace/package.json +++ b/packages/workspace/workspace/package.json @@ -1,7 +1,7 @@ { "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", + "version": "0.0.1-rc.1", "publishConfig": { "access": "restricted" }, From 3e79f7106e6017142a21037028f15781a39fce10 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:37:59 +0800 Subject: [PATCH 056/113] feat(client-runtime): add target-owned conversation snapshots --- .../src/client/contract/conversation.ts | 21 +++++-- .../src/client/conversation/event-registry.ts | 13 ++++- packages/client/runtime/src/client/index.ts | 37 ++---------- .../client/sessions/conversation-assembler.ts | 57 +++++++++++-------- .../src/client/sessions/conversation.ts | 9 ++- .../runtime/src/client/sessions/session.ts | 1 + .../client/runtime/tests/client-apply.spec.ts | 1 + .../tests/conversation-assembler.spec.ts | 23 ++++++-- .../tests/conversation-registry.spec.ts | 1 + packages/client/runtime/tests/session.spec.ts | 5 +- packages/client/test-runtime/src/fixtures.ts | 5 +- .../client/conversation-nodes/assistant.ts | 4 +- .../src/client/conversation-nodes/command.ts | 4 +- .../client/conversation-nodes/compaction.ts | 4 +- .../src/client/conversation-nodes/fallback.ts | 3 +- .../src/client/conversation-nodes/inbox.ts | 1 - .../src/client/conversation-nodes/message.ts | 5 +- .../src/client/conversation-nodes/retry.ts | 5 +- .../src/client/conversation-nodes/tool.ts | 4 +- .../client/conversation-nodes/turn-error.ts | 4 +- .../client/conversation-nodes/turn-tail.ts | 4 +- .../ui-conversation/tests/chat-stats.spec.tsx | 3 +- .../ui-conversation/tests/chat-view.spec.tsx | 6 +- .../tests/gate-branch-tails.spec.tsx | 6 +- .../ui-conversation/tests/input-bar.spec.tsx | 6 +- .../tests/input-matrix.spec.tsx | 6 +- .../tests/input-scenarios.spec.tsx | 6 +- .../ui-conversation/tests/queue-dock.spec.tsx | 6 +- .../ui-conversation/tests/skeleton.spec.tsx | 6 +- .../src/client/turn-deliverables.ts | 1 + .../tests/produced-files.spec.tsx | 2 +- .../ui-tool/tests/chat-code-subcalls.spec.tsx | 6 +- .../client/ui-tool/tests/diff-card.spec.tsx | 7 ++- .../client/ui-tool/tests/read-card.spec.tsx | 7 ++- .../client/ui-tool/tests/search-card.spec.tsx | 7 ++- .../ui-tool/tests/terminal-card.spec.tsx | 7 ++- .../client/ui-tool/tests/web-card.spec.tsx | 7 ++- 37 files changed, 183 insertions(+), 117 deletions(-) diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts index 9507046b33..7119980839 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/conversation/event-registry.ts b/packages/client/runtime/src/client/conversation/event-registry.ts index 381fff81b5..d9eabda538 100644 --- a/packages/client/runtime/src/client/conversation/event-registry.ts +++ b/packages/client/runtime/src/client/conversation/event-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/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts index bdd89f56a4..ee8e6b0eae 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/session.ts b/packages/client/runtime/src/client/sessions/session.ts index 9c1d86987c..8e984d3edc 100644 --- a/packages/client/runtime/src/client/sessions/session.ts +++ b/packages/client/runtime/src/client/sessions/session.ts @@ -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/tests/client-apply.spec.ts b/packages/client/runtime/tests/client-apply.spec.ts index 7c8a40e06b..7c2bdf9bc6 100644 --- a/packages/client/runtime/tests/client-apply.spec.ts +++ b/packages/client/runtime/tests/client-apply.spec.ts @@ -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..50380a20a3 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.map(asChatDefinition) + this.fallback = fallback === undefined ? undefined : asChatDefinition(fallback) + } entries(): readonly ConversationNodeDefinition[] { return this.definitions @@ -44,6 +50,12 @@ class TestEventDefinitions { } } +function asChatDefinition(definition: ConversationNodeDefinition): ConversationNodeDefinition { + return definition.buildViewNode === undefined || definition.target !== undefined + ? definition + : { ...definition, target: 'chat' } +} + class TestViewDefinitions { constructor(readonly definitions: readonly ConversationViewDefinition[]) {} @@ -93,7 +105,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, diff --git a/packages/client/runtime/tests/conversation-registry.spec.ts b/packages/client/runtime/tests/conversation-registry.spec.ts index 9f45b36c6a..0beaf1d5c2 100644 --- a/packages/client/runtime/tests/conversation-registry.spec.ts +++ b/packages/client/runtime/tests/conversation-registry.spec.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, 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/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/ui-conversation/src/client/conversation-nodes/assistant.ts b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts index 5baad5c37b..641a0287e4 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/assistant.ts @@ -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/command.ts b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts index 1517fbe718..692666fb66 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/command.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/command.ts @@ -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 2852019c3f..18f3205df7 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/compaction.ts @@ -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 6309c35b94..79bc97e636 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/fallback.ts @@ -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 92e611f77c..4d8fb6d3e2 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/inbox.ts @@ -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 d57a6d9d96..085127f9c5 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/message.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/message.ts @@ -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/retry.ts b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts index 31b80075a8..4a0f9f9fed 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/retry.ts @@ -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 46c838e980..0d6fb57cf3 100644 --- a/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts +++ b/packages/client/ui-conversation/src/client/conversation-nodes/tool.ts @@ -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 1f5a87add8..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 @@ -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 01bee27f3f..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 @@ -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/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..fd306eb132 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,7 @@ 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, } 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..6e0df4dd8c 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, diff --git a/packages/client/ui-conversation/tests/input-bar.spec.tsx b/packages/client/ui-conversation/tests/input-bar.spec.tsx index 59c0f9eefa..b9aa56f7c8 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, diff --git a/packages/client/ui-conversation/tests/input-matrix.spec.tsx b/packages/client/ui-conversation/tests/input-matrix.spec.tsx index 09cfe4ca66..bb6ba10990 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, diff --git a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx index 416d2fe620..0722ccb2e1 100644 --- a/packages/client/ui-conversation/tests/input-scenarios.spec.tsx +++ b/packages/client/ui-conversation/tests/input-scenarios.spec.tsx @@ -11,7 +11,9 @@ 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, 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/skeleton.spec.tsx b/packages/client/ui-conversation/tests/skeleton.spec.tsx index 7d7596a49e..4651a27bea 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, diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 9151f88869..0061400e7a 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -97,6 +97,7 @@ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[ /** Turn-local successful mutation accumulator; it publishes no view Node. */ export const deliverablesDefinition: ConversationNodeDefinition = { kind: 'deliverables', + target: 'chat', match: (event) => { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' } diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index f38faf8dd5..48303f2e54 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -73,7 +73,7 @@ interface TimelineSnapshot { class TestEventDefinitions { entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] } - fallbackEntry(): undefined { return undefined } + fallbackEntries(): readonly ConversationNodeDefinition[] { return [] } } class TestViewDefinitions { 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 1a7dd2d892..8840e6a19d 100644 --- a/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx +++ b/packages/client/ui-tool/tests/chat-code-subcalls.spec.tsx @@ -12,7 +12,8 @@ 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, diff --git a/packages/client/ui-tool/tests/diff-card.spec.tsx b/packages/client/ui-tool/tests/diff-card.spec.tsx index 600949f5e8..3990cba157 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' @@ -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 ef00460103..14baac6b9b 100644 --- a/packages/client/ui-tool/tests/read-card.spec.tsx +++ b/packages/client/ui-tool/tests/read-card.spec.tsx @@ -10,7 +10,9 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { cleanup, fireEvent, render } from '@testing-library/react' 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 { @@ -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..67d5eea6ca 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' @@ -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..c0af5adff7 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' @@ -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, diff --git a/packages/client/ui-tool/tests/web-card.spec.tsx b/packages/client/ui-tool/tests/web-card.spec.tsx index 44f63147a0..67ec839471 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' @@ -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, From f479c60b6dabb0c8061ef7ac8dbeb34ba67cc37a Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:38:22 +0800 Subject: [PATCH 057/113] feat(ui-trajectory): assemble registered conversation nodes --- .../src/client/TrajectoryView.tsx | 40 +- .../client/ui-trajectory/src/client/index.ts | 30 +- .../client/trajectory-assistant-definition.ts | 397 ++++++++++++++++++ .../trajectory-compaction-definition.ts | 139 ++++++ .../src/client/trajectory-contract.ts | 75 ++++ .../client/trajectory-definition-common.ts | 29 ++ .../client/trajectory-message-definitions.ts | 114 +++++ .../trajectory-request-header-definition.ts | 76 ++++ .../src/client/trajectory-snapshot-builder.ts | 222 ++++++++++ .../src/client/trajectory-tool-definition.ts | 250 +++++++++++ .../ui-trajectory/tests/client-bundle.spec.ts | 16 +- .../client/ui-trajectory/tests/views.spec.tsx | 141 ++++--- 12 files changed, 1425 insertions(+), 104 deletions(-) create mode 100644 packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-contract.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-definition-common.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 6d77f907f1..95476ac851 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,11 +1,11 @@ /** Trajectory view: compact summary over a turn-aware event ledger. */ -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useMemo, 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 { AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot, - SessionHistoryFace, SnapshotStore, + SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' import { deriveTrajectoryContextBranches, trajectoryBranchContainsRequest, @@ -27,6 +27,7 @@ import { type TrajectoryTimeRange, } from './timeline.ts' import { trajectoryRecordId } from './trajectory-record.ts' +import { EMPTY_TRAJECTORY_SNAPSHOT } from './trajectory-snapshot-builder.ts' import css from './views.module.css' const EMPTY_TURN_IDS: ReadonlySet = new Set() @@ -64,14 +65,12 @@ 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 } @@ -184,7 +183,7 @@ function mergeSearchMatches( } export function TrajectoryView({ - useHistory, useDuration, loadHistoryTail, loadOlderHistory, setActualDuration, + useSession, useDuration, loadOlder, setActualDuration, inspect, onInspectDone, }: ConvViewProps & InjectFace) { const [collapsedTurns, setCollapsedTurns] = useState>(EMPTY_TURN_IDS) @@ -204,23 +203,15 @@ 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 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 @@ -518,11 +509,8 @@ export function TrajectoryView({ } const loadEarlierHistory = useCallback(() => { - const signal = historyControllerRef.current?.signal - return signal?.aborted === false - ? loadOlderHistory(signal) - : Promise.resolve(false) - }, [loadOlderHistory]) + return loadOlder() + }, [loadOlder]) return (
diff --git a/packages/client/ui-trajectory/src/client/index.ts b/packages/client/ui-trajectory/src/client/index.ts index e1d3a5cc17..1f48a4711d 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -9,9 +9,15 @@ import type { SessionId } from '@deepseek-ai/dsh-client-runtime/client' import type {} from '@deepseek-ai/dsh-client-ui-conversation/client' import { createTrajectoryDurationStore } from './duration-store.ts' import { TrajectoryView, type TrajectoryViewInjected } from './TrajectoryView.tsx' +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' -/** Required services: the conversation view slot and independent history source. */ -export const inject = ['slots', 'sessionHistory'] +/** Required services: the conversation slot, registries, and ordinary Session paging. */ +export const inject = ['slots', 'conversationEvents', 'conversationViews', 'sessions'] /** * Client plugin body: register the trajectory view tab. The registration @@ -20,17 +26,29 @@ export const inject = ['slots', 'sessionHistory'] */ export function apply(ctx: Context): void { 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', 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 hadMore = session.getSnapshot().hasMore + await session.loadOlder() + return hadMore + }, setActualDuration: (value) => { duration.set(value) }, } }, 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..8f0d9ff430 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -0,0 +1,397 @@ +import type { Context } from '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' + +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 }), + }), +} + +/** Register the Trajectory Assistant lifecycle. */ +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..65bab06059 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts @@ -0,0 +1,139 @@ +import type { Context } from '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. */ +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..e261eeb9fc --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-contract.ts @@ -0,0 +1,75 @@ +import type { + AssistantMessageNode, ConversationContext, 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 data: TrajectoryContribution +} + +/** Stage-oriented Trajectory data assembled from registered business Contexts. */ +export interface TrajectorySnapshot { + readonly eventNodes: readonly ConversationNode[] + readonly contexts: readonly ConversationContext[] + readonly requests: readonly RequestView[] + readonly callSchemas: ReadonlyMap + readonly interruptedNodes: readonly ConversationNode[] + 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..d11034b9b9 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts @@ -0,0 +1,29 @@ +import type { + ConversationLocation, ConversationNodeContext, +} from '@deepseek-ai/dsh-client-runtime/client' +import type { + TrajectoryContribution, TrajectoryConversationViewNode, +} from './trajectory-contract.ts' + +/** Resolve the best loaded Location for one target-local Context. */ +export function trajectoryContextLocation( + context: ConversationNodeContext, +): ConversationLocation { + return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } +} + +/** Wrap one contribution in the Engine-owned target envelope. */ +export function trajectoryNode( + context: ConversationNodeContext, + anchorSeq: number, + data: TrajectoryContribution, +): TrajectoryConversationViewNode { + return { + key: context.key, + kind: context.kind, + id: context.id, + target: 'trajectory', + anchorSeq, + 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..5f6b203e28 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -0,0 +1,114 @@ +import type { Context } from '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' + +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 }), +} + +/** Register Trajectory-owned inbox classification and message records. */ +export function registerTrajectoryMessageDefinitions(ctx: Context): void { + ctx.conversationEvents.register(trajectoryInboxDefinition) + ctx.conversationEvents.register(trajectoryMessageDefinition) +} 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..a6ec4e4597 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts @@ -0,0 +1,76 @@ +import type { Context } from '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. */ +export function registerTrajectoryRequestHeaderDefinition(ctx: Context): void { + ctx.conversationEvents.register(trajectoryRequestHeaderDefinition) +} 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..613cd1e750 --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -0,0 +1,222 @@ +import type { Context } from '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[] = [] +const EMPTY_CONTEXTS = [{ id: 0, nodes: EMPTY_LIST }] + +/** Stable empty target used until a Session has assembled Trajectory records. */ +export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = { + eventNodes: EMPTY_LIST, + contexts: EMPTY_CONTEXTS, + requests: EMPTY_LIST, + callSchemas: new Map(), + interruptedNodes: EMPTY_LIST, + partial: null, + runningCalls: EMPTY_LIST, +} + +function coordinates( + header: TrajectoryRequestHeaderState, +): { turn?: number; step?: number } { + const location = header.location + if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } + if (location.kind === 'turn') return { turn: location.turn.turn } + return {} +} + +function headerFor( + request: Extract, + headers: readonly TrajectoryRequestHeaderState[], +): TrajectoryRequestHeaderState | undefined { + const exact = headers.findLast((header) => { + const location = coordinates(header) + return location.turn === request.turn && location.step === request.step + }) + return exact ?? headers.findLast(header => header.seq < request.startSeq) +} + +function applyHeader( + request: Extract, + header: TrajectoryRequestHeaderState | undefined, +): Extract { + return header === undefined + ? request + : { + ...request, + prompt: header.prompt, + requestConfig: header.prompt.config, + ...(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, + tools: readonly ConversationPromptSnapshot['tools'][number][], + output: Map, +): void { + const name = 'kind' in block ? block.call?.name : block.name + const schema = name === undefined || name === null + ? undefined + : tools.find(candidate => candidate.name === name) + if (schema !== undefined) output.set(block.callId, schema) + for (const child of block.subCalls) captureSchemas(child, tools, output) +} + +function interruptCompactions( + requests: RequestView[], + boundaries: readonly { seq: number; time: number }[], +): void { + for (const boundary of boundaries) { + const index = requests.findLastIndex(request => + request.purpose === 'compaction' + && request.startSeq < boundary.seq + && request.status === 'running') + 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 { + for (const ending of endings) { + if (ending.error === undefined) continue + const index = requests.findLastIndex(request => + request.purpose === 'assistant' && request.turn === ending.turn) + 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() + 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) + return this.snapshot() + } + + apply(input: { + readonly upserts: readonly TrajectoryConversationViewNode[] + }): TrajectorySnapshot { + for (const node of input.upserts) this.nodes.set(node.key, node) + return this.snapshot() + } + + private snapshot(): TrajectorySnapshot { + const contributions = [...this.nodes.values()] + .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) + const headers = contributions.flatMap(node => node.data.kind === 'request-header' + ? [node.data.header] + : []) + const finalized: ConversationNode[] = [] + const requests: RequestView[] = [] + const boundaries: { seq: number; time: number }[] = [] + const turnEndings: { turn: number; time: number; error?: string }[] = [] + const callSchemas = new Map() + let partial: TrajectorySnapshot['partial'] = null + const runningCalls: TrajectorySnapshot['runningCalls'][number][] = [] + + for (const contribution of contributions) { + const data = contribution.data + if (data.kind === 'node') { + finalized.push(data.node) + continue + } + if (data.kind === 'assistant') { + const header = data.request === undefined ? undefined : headerFor(data.request, headers) + if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.prompt)) + if (data.partial !== null) partial = data.partial + if (data.request !== undefined) requests.push(applyHeader(data.request, header)) + continue + } + if (data.kind === 'tool') { + if ('kind' in data.root) finalized.push(data.root) + else runningCalls.push(data.root) + const header = headers.findLast(candidate => candidate.seq < contribution.anchorSeq) + if (header !== undefined) captureSchemas(data.root, header.prompt.tools, 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 + } + if (data.kind === 'turn-end') { + 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, + contexts: [{ id: 0, nodes: eventNodes }], + requests, + callSchemas, + interruptedNodes: EMPTY_LIST, + partial, + runningCalls, + } + } +} + +/** Trajectory target factory preserving the existing stage-oriented view model. */ +export const trajectoryViewDefinition: ConversationViewDefinition< + TrajectoryConversationViewNode, + TrajectorySnapshot +> = { + target: 'trajectory', + create: () => new TrajectorySnapshotBuilder(), +} + +/** Register the legacy-shape Trajectory target builder. */ +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..353d89070b --- /dev/null +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -0,0 +1,250 @@ +import type { Context } from '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' + +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 + for (let depth = 0; cursor !== undefined && depth <= MAX_DEPTH; depth++) { + if (cursor === child) return false + cursor = state.parents.get(cursor) + } + return cursor === undefined +} + +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 }) + }, +} + +/** Register the Trajectory Tool lifecycle. */ +export function registerTrajectoryToolDefinition(ctx: Context): void { + ctx.conversationEvents.register(trajectoryToolDefinition) +} diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 511cf03b5c..902363b719 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -10,7 +10,9 @@ import { readFileSync } from 'node:fs' import { resolve } from 'node:path' 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,21 +63,25 @@ 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', + ]) }) 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. + ctx.provide('sessions', { binding: () => undefined }) const fiber = ctx.plugin(surface as { apply: (ctx: Context) => void }) await fiber.await() expect(slots.entries('conversation.view').map(e => e.options.id)).toEqual(['trajectory']) diff --git a/packages/client/ui-trajectory/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 26adf85ba8..ab3f23d829 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -13,12 +13,15 @@ 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 { + ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, + EMPTY_CHAT_SNAPSHOT, +} from '@deepseek-ai/dsh-client-runtime/client' import type { UseSession } from '@deepseek-ai/dsh-client-web-react' 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 { @@ -35,9 +38,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 +72,55 @@ const NODES = [ function historySnapshot( nodes: ConversationSnapshot['nodes'], - inspection: Partial = {}, -): SessionHistorySnapshot { + inspection: Partial = {}, +): ConversationSnapshot { + const trajectory: TrajectorySnapshot = { + eventNodes: nodes, + contexts: [{ id: 0, nodes }], + requests: [], + callSchemas: new Map(), + interruptedNodes: [], + partial: null, + runningCalls: [], + ...inspection, + } return { - state: 'ready', - error: null, + sessionId: SID, + views: { + get: target => target === 'trajectory' ? trajectory : undefined, + } as ConversationSnapshot['views'], + 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, - baseSeq: nodes[0]?.seq ?? 0, - inspection: { - eventNodes: nodes, - contexts: [{ id: 0, nodes }], - requests: [], - callSchemas: new Map(), - interruptedNodes: [], - partial: null, - runningCalls: [], - ...inspection, - }, + 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), } } @@ -112,11 +135,8 @@ function standaloneDuration(): Pick< } 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) as UseSession } } /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ @@ -149,16 +169,19 @@ function standaloneProps(nodes: ConversationSnapshot['nodes']): ConvViewProps { 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 +190,9 @@ 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 }) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, slots, fiber, loadHistoryTail, loadOlderHistory } + return { ctx, slots, fiber, loadOlder } } /** Tab projection twin of apply's viewTabs (the render-side consumption path). */ @@ -181,13 +203,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) as UseSession const chat = createChatStore().create() const views = { list: () => tabsOf(slots), @@ -215,10 +232,8 @@ 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), useDuration: bindSnapshotSelector(trajectory.hooks.duration), } })() @@ -322,13 +337,9 @@ 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('opens a local record inspector and switches payload tabs without opening chat details', async () => { @@ -1162,9 +1173,8 @@ describe('TrajectoryView branches', () => { Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} + useSession={bindSnapshotSelector(store)} + loadOlder={vi.fn(() => Promise.resolve(false))} />, ) @@ -1196,9 +1206,8 @@ describe('TrajectoryView branches', () => { Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} + useSession={bindSnapshotSelector(store)} + loadOlder={vi.fn(() => Promise.resolve(false))} />, ) const row = screen.getByRole('row', { name: /stable rewind response/ }) @@ -1225,9 +1234,8 @@ describe('TrajectoryView branches', () => { Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} + useSession={bindSnapshotSelector(store)} + loadOlder={vi.fn(() => Promise.resolve(false))} />, ) fireEvent.click(screen.getByRole('row', { name: /selected current response/ })) @@ -1274,9 +1282,8 @@ describe('TrajectoryView branches', () => { Promise.resolve())} - loadOlderHistory={vi.fn(() => Promise.resolve(false))} + useSession={bindSnapshotSelector(store)} + loadOlder={vi.fn(() => Promise.resolve(false))} />, ) From c828d38f5193c775132dbc300e8bf582f23d6eb4 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 18:38:50 +0800 Subject: [PATCH 058/113] refactor(client-runtime): remove legacy history fold --- .../src/client/contract/session-history.ts | 43 -- .../client/session-history/history-fold.ts | 428 ----------------- .../src/client/session-history/service.ts | 66 --- .../src/client/session-history/source.ts | 432 ------------------ .../runtime/src/client/sessions/history.ts | 121 ----- .../src/client/sessions/request-inspection.ts | 328 +------------ .../client/runtime/tests/history-fold.spec.ts | 232 ---------- .../runtime/tests/request-inspection.spec.ts | 319 ------------- .../tests/session-history-source.spec.ts | 180 -------- scripts/gen-cordis-catalog.ts | 1 - 10 files changed, 4 insertions(+), 2146 deletions(-) delete mode 100644 packages/client/runtime/src/client/contract/session-history.ts delete mode 100644 packages/client/runtime/src/client/session-history/history-fold.ts delete mode 100644 packages/client/runtime/src/client/session-history/service.ts delete mode 100644 packages/client/runtime/src/client/session-history/source.ts delete mode 100644 packages/client/runtime/src/client/sessions/history.ts delete mode 100644 packages/client/runtime/tests/history-fold.spec.ts delete mode 100644 packages/client/runtime/tests/request-inspection.spec.ts delete mode 100644 packages/client/runtime/tests/session-history-source.spec.ts 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/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 4705b6566d..0000000000 --- a/packages/client/runtime/src/client/session-history/service.ts +++ /dev/null @@ -1,66 +0,0 @@ -import type { Context } from '@deepseek-ai/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/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/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/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/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/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/scripts/gen-cordis-catalog.ts b/scripts/gen-cordis-catalog.ts index 79419be5ed..4a26b80c28 100644 --- a/scripts/gen-cordis-catalog.ts +++ b/scripts/gen-cordis-catalog.ts @@ -132,7 +132,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', From a342b329e358fdb23274fb6c086d5180e8aed9a1 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:05:23 +0800 Subject: [PATCH 059/113] docs(client): document trajectory conversation assembly --- ...lient-conversation-node-assembly.i18n.yaml | 4 +- ...08-09-client-conversation-node-assembly.md | 21 +++++----- ...09-client-conversation-node-assembly.zh.md | 21 +++++----- ...-27-trajectory-inspection-ledger.i18n.yaml | 4 +- ...2026-07-27-trajectory-inspection-ledger.md | 10 ++--- ...6-07-27-trajectory-inspection-ledger.zh.md | 10 ++--- ...b-context-source-and-steer-marks.i18n.yaml | 4 +- ...8-04-web-context-source-and-steer-marks.md | 4 +- ...4-web-context-source-and-steer-marks.zh.md | 4 +- ...6-07-24-web-gui-browser-e2e-lane.i18n.yaml | 4 +- .../2026-07-24-web-gui-browser-e2e-lane.md | 2 +- .../2026-07-24-web-gui-browser-e2e-lane.zh.md | 2 +- packages/client/runtime/README.i18n.yaml | 4 +- packages/client/runtime/README.md | 11 +++--- packages/client/runtime/README.zh.md | 11 +++--- packages/client/runtime/package.json | 1 - .../src/client/contract/conversation.ts | 2 +- .../client/sessions/conversation-assembler.ts | 2 +- .../ui-conversation/tests/chat-view.spec.tsx | 3 +- .../src/client/turn-deliverables.ts | 2 - .../tests/produced-files.spec.tsx | 2 +- .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- .../client/trajectory-assistant-definition.ts | 32 +++++++++------- .../trajectory-compaction-definition.ts | 38 ++++++++++--------- .../client/trajectory-definition-common.ts | 16 +++++++- .../client/trajectory-message-definitions.ts | 32 +++++++++------- .../trajectory-request-header-definition.ts | 12 ++++-- .../src/client/trajectory-snapshot-builder.ts | 18 +++++---- .../src/client/trajectory-tool-definition.ts | 6 ++- .../client/ui-trajectory/tests/views.spec.tsx | 7 ++-- pnpm-lock.yaml | 3 -- 33 files changed, 161 insertions(+), 139 deletions(-) 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..1dec922826 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: 1d5fd20bfa8c3b370f736937d54a668ca7f19ca3 +2026-08-09-client-conversation-node-assembly.zh.md: 6f0acc448950cdedcb249ada8cfd931a21765b3e 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..1d5fd20bfa 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,7 @@ 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. ## Runtime and render path @@ -339,20 +339,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 +379,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 +403,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..6f0acc4489 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,7 @@ 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 契约。 ## Runtime and render path @@ -339,20 +339,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 +379,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 +403,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/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml b/.agents/notes/implemented/feature/2026-07-27-trajectory-inspection-ledger.i18n.yaml index b73a552168..1e7284378c 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: c09213d35e984ca717d283d45259f61d413407a3 +2026-07-27-trajectory-inspection-ledger.zh.md: 9d2c615dea5b0774201118a0b0abb9862a228690 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..c09213d35e 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 @@ -16,16 +16,16 @@ Trajectory has to make prose, machine payloads, token usage, timing, and nested - 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. Its Definitions and target builder derive event order, context lineage, schema index, and Requests without making those structures part of the Chat snapshot. - 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. +- 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 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. - 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 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 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..9d2c615dea 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 @@ -16,16 +16,16 @@ Status: implemented - 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 - 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 - 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 -- 客户端运行时提供独立于 Session 和 SessionManager 的只读历史数据源。每个已激活的数据源自行拥有原始条目、分页、实时缺口修复和重连重建;普通会话快照仍然只是 Chat 所需的折叠投影。Trajectory 在挂载期间打开该数据源的尾部,当用户到达已加载范围顶部时请求一页更早的历史,再按需派生事件顺序、上下文谱系、schema 索引和请求,避免让所有会话消费方承担这些结构。 +- Session 统一拥有一份连续 Event 窗口、分页状态、实时缺口修复与重连重建。Chat 与 Trajectory 针对共享的 `ConversationNodeAssembler` 分别注册业务 Definition;Trajectory 从 `Session.views` 读取自己的 target snapshot,并在用户到达已加载范围顶部时请求一页更早的 Session 历史。它的 Definition 与 target builder 派生事件顺序、上下文谱系、schema 索引和请求,无须把这些结构放进 Chat snapshot。 - 普通生成调用与压缩调用形成一条按时间排序的请求投影,以用途区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展。 - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 选择记录或请求后,Trajectory 内部会打开检查器,其标签页和概述区域随实体类型变化:Markdown 消息提供渲染内容、来源字段、提供方/模型字段和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。 - 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。 -- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早页面仍在等待时,明确的加载行会遮住真实记录。原始窗口的基准序号即使在一页未增加任何 surface 可见节点时,也能检测到这次向前补页。 +- 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 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` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 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-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/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/packages/client/runtime/README.i18n.yaml b/packages/client/runtime/README.i18n.yaml index 7dc91083c9..9743f813da 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: d84cd793c34242759ad04edf0debb91558ec3dfc +README.zh.md: e7a74c454f24fcec5e797427c21222b1dc258b44 diff --git a/packages/client/runtime/README.md b/packages/client/runtime/README.md index 1ec6cc38ae..d84cd793c3 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. @@ -42,17 +41,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..e7a74c454f 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 注册仍会抛出异常。 @@ -42,17 +41,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 749e564ef5..86d51cd7a3 100644 --- a/packages/client/runtime/package.json +++ b/packages/client/runtime/package.json @@ -46,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:^", diff --git a/packages/client/runtime/src/client/contract/conversation.ts b/packages/client/runtime/src/client/contract/conversation.ts index 7119980839..26e7e43c67 100644 --- a/packages/client/runtime/src/client/contract/conversation.ts +++ b/packages/client/runtime/src/client/contract/conversation.ts @@ -116,7 +116,7 @@ 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( + get>( target: Target, ): ConversationViewSnapshotMap[Target] | undefined } diff --git a/packages/client/runtime/src/client/sessions/conversation-assembler.ts b/packages/client/runtime/src/client/sessions/conversation-assembler.ts index ee8e6b0eae..85c59a4053 100644 --- a/packages/client/runtime/src/client/sessions/conversation-assembler.ts +++ b/packages/client/runtime/src/client/sessions/conversation-assembler.ts @@ -324,7 +324,7 @@ export class ConversationNodeAssembler implements ConversationViewSnapshotStore return this.views.get(target)?.snapshot } - get( + get>( target: Target, ): ConversationViewSnapshotMap[Target] | undefined { return this.snapshot(target) as ConversationViewSnapshotMap[Target] | undefined diff --git a/packages/client/ui-conversation/tests/chat-view.spec.tsx b/packages/client/ui-conversation/tests/chat-view.spec.tsx index fd306eb132..084e4ffa3a 100644 --- a/packages/client/ui-conversation/tests/chat-view.spec.tsx +++ b/packages/client/ui-conversation/tests/chat-view.spec.tsx @@ -49,7 +49,8 @@ type RoutedChatNodeOwner = ChatNodeOwnerProps & { readonly node: ChatNode } function snapshotBase(): ConversationSnapshot { return { - sessionId: SID, views: EMPTY_CONVERSATION_VIEWS, 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, } diff --git a/packages/client/ui-deliverables/src/client/turn-deliverables.ts b/packages/client/ui-deliverables/src/client/turn-deliverables.ts index 0061400e7a..e63bca2e63 100644 --- a/packages/client/ui-deliverables/src/client/turn-deliverables.ts +++ b/packages/client/ui-deliverables/src/client/turn-deliverables.ts @@ -97,7 +97,6 @@ export function selectProducedFiles(owner: TurnTailOwnerProps): readonly string[ /** Turn-local successful mutation accumulator; it publishes no view Node. */ export const deliverablesDefinition: ConversationNodeDefinition = { kind: 'deliverables', - target: 'chat', match: (event) => { if (event.type === 'turn/start') return { id: String(event.data.turn), role: 'start' } if (event.type === 'tool/call') return { id: String(event.data.turn), role: 'update' } @@ -137,7 +136,6 @@ export const deliverablesDefinition: ConversationNodeDefinition null, } /** diff --git a/packages/client/ui-deliverables/tests/produced-files.spec.tsx b/packages/client/ui-deliverables/tests/produced-files.spec.tsx index 48303f2e54..31289b38de 100644 --- a/packages/client/ui-deliverables/tests/produced-files.spec.tsx +++ b/packages/client/ui-deliverables/tests/produced-files.spec.tsx @@ -73,7 +73,7 @@ interface TimelineSnapshot { class TestEventDefinitions { entries(): readonly ConversationNodeDefinition[] { return [deliverablesDefinition] } - fallbackEntries(): readonly ConversationNodeDefinition[] { return [] } + fallbackEntry(): ConversationNodeDefinition | undefined { return undefined } } class TestViewDefinitions { diff --git a/packages/client/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 7fecd92d74..17551b0cc8 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: 75bd9ddf452634460be01e1b89cd5a1a14a1593f +README.zh.md: b5cd53dd50e43b96e2e832c96cb7e93f859c1993 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 5b8c0cd111..75bd9ddf45 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 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 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 context lineage and 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..b5cd53dd50 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 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 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/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index 8f0d9ff430..d610f6979b 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -255,17 +255,17 @@ function assistantRequest( ...(state.retry === undefined ? {} : { - error: state.retry.message, - retry: state.retry.retry, - ...(state.retry.maxRetries === undefined ? {} : { maxRetries: state.retry.maxRetries }), - retryDelayMs: state.retry.delayMs, - }), + 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 }), - }), + resultSeq: node.seq, + ...(node.provenance === undefined ? {} : { provenance: node.provenance }), + }), ...(state.usage === undefined ? {} : { usage: state.usage }), } } @@ -383,14 +383,18 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition = { 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 }), - }), + kind: 'turn-end', + turn: context.state.turn, + time: context.state.time, + ...(context.state.error === undefined ? {} : { error: context.state.error }), + }), } -/** Register the Trajectory Assistant lifecycle. */ +/** + * 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 index 65bab06059..de6d2af21b 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts @@ -61,18 +61,18 @@ function requestFromState( ...(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 }), - }), + 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 } : {}), } } @@ -126,13 +126,17 @@ const trajectorySessionEndDefinition: ConversationNodeDefinition context.state === undefined ? null : trajectoryNode(context, context.state.seq, { - kind: 'session-end', - seq: context.state.seq, - time: context.state.time, - }), + kind: 'session-end', + seq: context.state.seq, + time: context.state.time, + }), } -/** Register Trajectory compaction requests and session boundaries. */ +/** + * 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-definition-common.ts b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts index d11034b9b9..8c9c7d6489 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts @@ -5,14 +5,26 @@ import type { TrajectoryContribution, TrajectoryConversationViewNode, } from './trajectory-contract.ts' -/** Resolve the best loaded Location for one target-local Context. */ +/** + * Resolve the best loaded Location for one target-local Context. + * + * @param context - Context whose loaded matches provide the Location. + * @returns The start Location, first-match Location, or unresolved fallback. + */ export function trajectoryContextLocation( context: ConversationNodeContext, ): ConversationLocation { return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } } -/** Wrap one contribution in the Engine-owned target envelope. */ +/** + * 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, diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts index 5f6b203e28..a35b6080db 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -86,20 +86,20 @@ const trajectoryMessageDefinition: ConversationNodeDefinition = { ?.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: '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, - } + 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 @@ -107,7 +107,11 @@ const trajectoryMessageDefinition: ConversationNodeDefinition = { : trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }), } -/** Register Trajectory-owned inbox classification and message records. */ +/** + * 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-request-header-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts index a6ec4e4597..4d8a0c9006 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts @@ -65,12 +65,16 @@ const trajectoryRequestHeaderDefinition: ConversationNodeDefinition context.state === undefined ? null : trajectoryNode(context, context.state.seq, { - kind: 'request-header', - header: context.state, - }), + kind: 'request-header', + header: context.state, + }), } -/** Register Trajectory request-header facts. */ +/** + * 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-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 613cd1e750..717172c9d8 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -50,11 +50,11 @@ function applyHeader( return header === undefined ? request : { - ...request, - prompt: header.prompt, - requestConfig: header.prompt.config, - ...(header.change === undefined ? {} : { promptChange: header.change }), - } + ...request, + prompt: header.prompt, + requestConfig: header.prompt.config, + ...(header.change === undefined ? {} : { promptChange: header.change }), + } } function withRequestConfig( @@ -70,7 +70,7 @@ function captureSchemas( output: Map, ): void { const name = 'kind' in block ? block.call?.name : block.name - const schema = name === undefined || name === null + const schema = name === undefined ? undefined : tools.find(candidate => candidate.name === name) if (schema !== undefined) output.set(block.callId, schema) @@ -216,7 +216,11 @@ export const trajectoryViewDefinition: ConversationViewDefinition< create: () => new TrajectorySnapshotBuilder(), } -/** Register the legacy-shape Trajectory target builder. */ +/** + * 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 index 353d89070b..c72c6c8709 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -244,7 +244,11 @@ const trajectoryToolDefinition: ConversationNodeDefinition = { }, } -/** Register the Trajectory Tool lifecycle. */ +/** + * 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/tests/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index ab3f23d829..2439fb2de5 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -17,7 +17,6 @@ import { ConversationEventRegistry, ConversationViewRegistry, createSnapshotStore, EMPTY_CHAT_SNAPSHOT, } from '@deepseek-ai/dsh-client-runtime/client' -import type { UseSession } from '@deepseek-ai/dsh-client-web-react' import { SlotsService } from '@deepseek-ai/dsh-client-runtime/client' import type { ConversationSnapshot, RequestView, @@ -88,7 +87,7 @@ function historySnapshot( sessionId: SID, views: { get: target => target === 'trajectory' ? trajectory : undefined, - } as ConversationSnapshot['views'], + }, chat: EMPTY_CHAT_SNAPSHOT, nodes, turnTimings: new Map(), @@ -136,7 +135,7 @@ function standaloneDuration(): Pick< function fakeSession(nodes: ConversationSnapshot['nodes']) { const store = createSnapshotStore(historySnapshot(nodes)) - return { store, useSession: bindSnapshotSelector(store) as UseSession } + return { store, useSession: bindSnapshotSelector(store) } } /** Empty sessions-list hook; breadcrumbs therefore fall back to the raw id. */ @@ -204,7 +203,7 @@ 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 = sessionSnapshots.get(slots) ?? createSnapshotStore(historySnapshot(nodes)) - const useSession = bindSnapshotSelector(sessionSnapshot) as UseSession + const useSession = bindSnapshotSelector(sessionSnapshot) const chat = createChatStore().create() const views = { list: () => tabsOf(slots), diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 646244da69..40f2643417 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1771,9 +1771,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 From 3469de58eb178e7662ece6ce061a26a7b7eaacd8 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 19:26:54 +0800 Subject: [PATCH 060/113] fix(ui-trajectory): consume prompt changes once --- .../src/client/trajectory-snapshot-builder.ts | 11 +++- .../tests/snapshot-builder.spec.ts | 62 +++++++++++++++++++ 2 files changed, 71 insertions(+), 2 deletions(-) create mode 100644 packages/client/ui-trajectory/tests/snapshot-builder.spec.ts diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 717172c9d8..585975b0aa 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -46,6 +46,7 @@ function headerFor( function applyHeader( request: Extract, header: TrajectoryRequestHeaderState | undefined, + includeChange: boolean, ): Extract { return header === undefined ? request @@ -53,7 +54,7 @@ function applyHeader( ...request, prompt: header.prompt, requestConfig: header.prompt.config, - ...(header.change === undefined ? {} : { promptChange: header.change }), + ...(includeChange && header.change !== undefined ? { promptChange: header.change } : {}), } } @@ -150,6 +151,7 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< const boundaries: { seq: number; time: number }[] = [] const turnEndings: { turn: number; time: number; error?: string }[] = [] const callSchemas = new Map() + const consumedPromptChanges = new Set() let partial: TrajectorySnapshot['partial'] = null const runningCalls: TrajectorySnapshot['runningCalls'][number][] = [] @@ -163,7 +165,12 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< const header = data.request === undefined ? undefined : headerFor(data.request, headers) if (data.node !== undefined) finalized.push(withRequestConfig(data.node, header?.prompt)) if (data.partial !== null) partial = data.partial - if (data.request !== undefined) requests.push(applyHeader(data.request, header)) + 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') { 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..87e484a433 --- /dev/null +++ b/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from 'vitest' +import type { RequestView } from '@deepseek-ai/dsh-client-runtime/client' +import type { TrajectoryConversationViewNode } 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', + } +} + +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, + 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, + 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]) + }) +}) From fc4df896a089956594a94f4da945ae9c44659328 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:38:51 +0800 Subject: [PATCH 061/113] perf(ui-trajectory): index trajectory snapshot assembly --- .../src/client/trajectory-snapshot-builder.ts | 130 +++++++++---- .../tests/snapshot-builder.spec.ts | 171 +++++++++++++++++- 2 files changed, 262 insertions(+), 39 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 585975b0aa..2f4c697275 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -11,6 +11,8 @@ import type { const EMPTY_LIST: readonly never[] = [] const EMPTY_CONTEXTS = [{ id: 0, nodes: EMPTY_LIST }] +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 = { @@ -23,31 +25,31 @@ export const EMPTY_TRAJECTORY_SNAPSHOT: TrajectorySnapshot = { runningCalls: EMPTY_LIST, } -function coordinates( - header: TrajectoryRequestHeaderState, -): { turn?: number; step?: number } { +function stepKey(turn: number, step: number): string { + return `${turn}\u0000${step}` +} + +function headerStepKey(header: TrajectoryRequestHeaderState): string | undefined { const location = header.location - if (location.kind === 'step') return { turn: location.turn.turn, step: location.step.step } - if (location.kind === 'turn') return { turn: location.turn.turn } - return {} + return location.kind === 'step' + ? stepKey(location.turn.turn, location.step.step) + : undefined } function headerFor( - request: Extract, - headers: readonly TrajectoryRequestHeaderState[], + request: AssistantRequest, + headersByStep: ReadonlyMap, + previous: TrajectoryRequestHeaderState | undefined, ): TrajectoryRequestHeaderState | undefined { - const exact = headers.findLast((header) => { - const location = coordinates(header) - return location.turn === request.turn && location.step === request.step - }) - return exact ?? headers.findLast(header => header.seq < request.startSeq) + return headersByStep.get(stepKey(request.turn, request.step)) + ?? (previous !== undefined && previous.seq < request.startSeq ? previous : undefined) } function applyHeader( - request: Extract, + request: AssistantRequest, header: TrajectoryRequestHeaderState | undefined, includeChange: boolean, -): Extract { +): AssistantRequest { return header === undefined ? request : { @@ -67,26 +69,39 @@ function withRequestConfig( function captureSchemas( block: ToolCallBlock, - tools: readonly ConversationPromptSnapshot['tools'][number][], - output: Map, + toolsByName: ReadonlyMap, + output: Map, ): void { const name = 'kind' in block ? block.call?.name : block.name - const schema = name === undefined - ? undefined - : tools.find(candidate => candidate.name === 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, tools, output) + 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) { - const index = requests.findLastIndex(request => - request.purpose === 'compaction' - && request.startSeq < boundary.seq - && request.status === 'running') + 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] = { @@ -102,10 +117,14 @@ 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 = requests.findLastIndex(request => - request.purpose === 'assistant' && request.turn === ending.turn) + const index = lastAssistantByTurn.get(ending.turn) + if (index === undefined) continue const request = requests[index] if (request?.purpose !== 'assistant') continue requests[index] = { @@ -123,6 +142,8 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< TrajectorySnapshot > { private readonly nodes = new Map() + private readonly positions = new Map() + private contributions: TrajectoryConversationViewNode[] = [] readonly empty = EMPTY_TRAJECTORY_SNAPSHOT replace(input: { @@ -130,39 +151,62 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< }): 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 { - for (const node of input.upserts) this.nodes.set(node.key, node) + 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 contributions = [...this.nodes.values()] - .sort((left, right) => left.anchorSeq - right.anchorSeq || left.key.localeCompare(right.key)) - const headers = contributions.flatMap(node => node.data.kind === 'request-header' - ? [node.data.header] - : []) + 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 requests: RequestView[] = [] const boundaries: { seq: number; time: number }[] = [] const turnEndings: { turn: number; time: number; error?: string }[] = [] - const callSchemas = new Map() + 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 contributions) { + 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) continue } if (data.kind === 'assistant') { - const header = data.request === undefined ? undefined : headerFor(data.request, headers) + 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) { @@ -176,8 +220,9 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< if (data.kind === 'tool') { if ('kind' in data.root) finalized.push(data.root) else runningCalls.push(data.root) - const header = headers.findLast(candidate => candidate.seq < contribution.anchorSeq) - if (header !== undefined) captureSchemas(data.root, header.prompt.tools, callSchemas) + if (previousHeader !== undefined && previousHeader.seq < contribution.anchorSeq) { + captureSchemas(data.root, previousTools, callSchemas) + } continue } if (data.kind === 'compaction') { @@ -212,6 +257,15 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< 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. */ diff --git a/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts b/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts index 87e484a433..d3cf64ac2d 100644 --- a/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts +++ b/packages/client/ui-trajectory/tests/snapshot-builder.spec.ts @@ -1,6 +1,8 @@ import { describe, expect, it } from 'vitest' import type { RequestView } from '@deepseek-ai/dsh-client-runtime/client' -import type { TrajectoryConversationViewNode } from '../src/client/trajectory-contract.ts' +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 { @@ -15,6 +17,47 @@ function assistantRequest(startSeq: number, step: number): Extract 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 = { @@ -59,4 +102,130 @@ describe('TrajectorySnapshotBuilder', () => { ? 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]) + }) }) From b3d3e423f22a77d99d9f4660b5dae51679a9958c Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 20:54:37 +0800 Subject: [PATCH 062/113] fix(ui-trajectory): retain parallel tool interruptions --- .../src/client/TrajectoryView.tsx | 5 ++-- .../src/client/context-branches.ts | 19 ++++++++++--- .../tests/context-branches.spec.ts | 27 +++++++++++++++++++ 3 files changed, 46 insertions(+), 5 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 95476ac851..70e92f72fd 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -9,6 +9,7 @@ import type { } from '@deepseek-ai/dsh-client-runtime/client' import { deriveTrajectoryContextBranches, trajectoryBranchContainsRequest, + trajectoryNodeIdentity, } from './context-branches.ts' import { TrajectoryTable, @@ -229,9 +230,9 @@ export function TrajectoryView({ 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])) + const selected = new Map(currentBranch.nodes.map(node => [trajectoryNodeIdentity(node), node])) for (const node of interruptedNodes) { - selected.set(node.seq, node) + selected.set(trajectoryNodeIdentity(node), node) } return [...selected.values()].sort((left, right) => left.seq - right.seq) }, [currentBranch.nodes, interruptedNodes]) diff --git a/packages/client/ui-trajectory/src/client/context-branches.ts b/packages/client/ui-trajectory/src/client/context-branches.ts index 2501511c50..2f665bb413 100644 --- a/packages/client/ui-trajectory/src/client/context-branches.ts +++ b/packages/client/ui-trajectory/src/client/context-branches.ts @@ -23,11 +23,24 @@ interface MutableBranch { key: string contexts: ConversationContext[] latest: ConversationContext - nodes: Map + nodes: Map startSeq: number retainedSurfaceSeqs: Set } +/** + * Resolve the identity used while coalescing one trajectory branch. + * Synthetic tool interruptions share their closing boundary seq, so their + * call ids distinguish parallel roots without inventing false event order. + * @param node - projected conversation node. + * @returns branch-local semantic identity. + */ +export function trajectoryNodeIdentity(node: ConversationNode): string { + return node.kind === 'tool-result' + ? `tool-result\u0000${String(node.seq)}\u0000${node.callId}` + : `seq\u0000${String(node.seq)}` +} + function isCompactionCheckpoint(node: ConversationNode): boolean { if (node.kind !== 'context') return false const source = node.source @@ -73,7 +86,7 @@ export function deriveTrajectoryContextBranches( latest: context, nodes: new Map( [...inheritedNodes, ...context.nodes.filter(node => !isCompactionCheckpoint(node))] - .map(node => [node.seq, node]), + .map(node => [trajectoryNodeIdentity(node), node]), ), startSeq: context.originSeq ?? Number.NEGATIVE_INFINITY, retainedSurfaceSeqs, @@ -85,7 +98,7 @@ export function deriveTrajectoryContextBranches( branch.contexts.push(context) branch.latest = context for (const node of context.nodes) { - if (!isCompactionCheckpoint(node)) branch.nodes.set(node.seq, node) + if (!isCompactionCheckpoint(node)) branch.nodes.set(trajectoryNodeIdentity(node), node) } } return mutable.map(branch => ({ diff --git a/packages/client/ui-trajectory/tests/context-branches.spec.ts b/packages/client/ui-trajectory/tests/context-branches.spec.ts index e608b9fd68..9885e9a4f9 100644 --- a/packages/client/ui-trajectory/tests/context-branches.spec.ts +++ b/packages/client/ui-trajectory/tests/context-branches.spec.ts @@ -34,6 +34,23 @@ const current = { source: { kind: 'plugin', plugin: 'rewind' }, } as ConversationNode +function interruptedTool(callId: string): ConversationNode { + return { + kind: 'tool-result', + seq: 19.2, + time: 20, + callId, + call: { name: 'parallel', argsRaw: '{}' }, + callTime: 10, + content: [], + isError: true, + error: { name: 'Interrupted', code: 'interrupted' }, + callView: null, + resultView: null, + subCalls: [], + } +} + function request( purpose: RequestView['purpose'], startSeq: number, @@ -99,4 +116,14 @@ describe('trajectory context branches', () => { expect(branch(1)?.key).toBe(branch(9)?.key) }) + + it('retains parallel tool interruptions that share one closing boundary', () => { + const branch = deriveTrajectoryContextBranches([{ + id: 0, + nodes: [interruptedTool('call-a'), interruptedTool('call-b')], + }])[0] + + expect(branch?.nodes.map(node => node.kind === 'tool-result' ? node.callId : undefined)) + .toEqual(['call-a', 'call-b']) + }) }) From 62f5d050390904852b8308b6bd4fee0246265d84 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:35:57 +0800 Subject: [PATCH 063/113] fix(ui-trajectory): tighten conversation assembly contracts --- ...-27-trajectory-inspection-ledger.i18n.yaml | 4 +- ...2026-07-27-trajectory-inspection-ledger.md | 8 +- ...6-07-27-trajectory-inspection-ledger.zh.md | 8 +- .../adding-a-conversation-node.i18n.yaml | 4 +- docs/cookbook/adding-a-conversation-node.md | 7 +- .../cookbook/adding-a-conversation-node.zh.md | 7 +- .../tests/conversation-assembler.spec.ts | 107 +++++-- .../tests/conversation-registry.spec.ts | 34 +++ .../client/ui-trajectory/README.i18n.yaml | 4 +- packages/client/ui-trajectory/README.md | 2 +- packages/client/ui-trajectory/README.zh.md | 2 +- packages/client/ui-trajectory/package.json | 6 + .../src/client/TrajectoryView.tsx | 66 +---- .../src/client/context-branches.ts | 135 --------- .../client/ui-trajectory/src/client/index.ts | 4 +- .../client/trajectory-assistant-definition.ts | 4 + .../src/client/trajectory-contract.ts | 4 +- .../client/trajectory-definition-common.ts | 16 +- .../client/trajectory-message-definitions.ts | 4 + .../src/client/trajectory-snapshot-builder.ts | 5 - .../src/client/trajectory-tool-definition.ts | 25 +- .../ui-trajectory/tests/client-bundle.spec.ts | 6 + .../tests/context-branches.spec.ts | 129 -------- .../tests/conversation-definitions.spec.ts | 276 ++++++++++++++++++ .../client/ui-trajectory/tests/views.spec.tsx | 174 ++--------- packages/client/ui-trajectory/tsconfig.json | 9 + pnpm-lock.yaml | 9 + 27 files changed, 525 insertions(+), 534 deletions(-) delete mode 100644 packages/client/ui-trajectory/src/client/context-branches.ts delete mode 100644 packages/client/ui-trajectory/tests/context-branches.spec.ts create mode 100644 packages/client/ui-trajectory/tests/conversation-definitions.spec.ts 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 1e7284378c..38bd4b3d9e 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: c09213d35e984ca717d283d45259f61d413407a3 -2026-07-27-trajectory-inspection-ledger.zh.md: 9d2c615dea5b0774201118a0b0abb9862a228690 +2026-07-27-trajectory-inspection-ledger.md: c46b9dbc564a8c3c83792335427614c92a015fce +2026-07-27-trajectory-inspection-ledger.zh.md: 20811f7a23fe1c9ee69dce24c975f6343eaff6df 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 c09213d35e..c46b9dbc56 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,17 +12,17 @@ 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. -- 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. Its Definitions and target builder derive event order, context lineage, schema index, and Requests without making those structures part of the Chat snapshot. +- 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. Its Definitions and target builder derive event order, the schema index, and Requests without making those structures part of the Chat snapshot. - 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 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 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. +- 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 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. @@ -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 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. +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 9d2c615dea..20811f7a23 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,17 +12,17 @@ Status: implemented **使用现有 DeepSeek 设计系统,渲染保留轮次结构的紧凑事件记录表,并提供局部记录检查器。** -- 记录表在以 `rewind` 划分的分支内按会话事件顺序展示。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。 +- 记录表在已加载窗口内按 Session Event 顺序展示物化后的业务记录。轮次边界由稍粗的分割线、原始轮次 id 和连续的左侧竖线表示;请求边界以融入该结构的小圆点表示,普通请求与压缩(compaction)请求在整个时间序列中共用一套编号。 - 事件类型与内容构成两个稳定列。角色标签朝内容侧对齐,嵌套子工具略微缩进,内容预览使用 CSS 截断以适应可用宽度。token 用量和耗时留在检查器中。 - 产品正文使用现有无衬线字体栈。轮次 id、token 数、耗时、工具调用、原始载荷和其他机器数据使用现有代码字体栈。 - 现有主题 token 同时负责亮色和暗色渲染。中性边框与表面构成整体结构;区分度较低的角色色帮助扫读而不表达成功或失败语义,业务蓝色则标识选择状态、链接和焦点。 -- Session 统一拥有一份连续 Event 窗口、分页状态、实时缺口修复与重连重建。Chat 与 Trajectory 针对共享的 `ConversationNodeAssembler` 分别注册业务 Definition;Trajectory 从 `Session.views` 读取自己的 target snapshot,并在用户到达已加载范围顶部时请求一页更早的 Session 历史。它的 Definition 与 target builder 派生事件顺序、上下文谱系、schema 索引和请求,无须把这些结构放进 Chat snapshot。 +- Session 统一拥有一份连续 Event 窗口、分页状态、实时缺口修复与重连重建。Chat 与 Trajectory 针对共享的 `ConversationNodeAssembler` 分别注册业务 Definition;Trajectory 从 `Session.views` 读取自己的 target snapshot,并在用户到达已加载范围顶部时请求一页更早的 Session 历史。它的 Definition 与 target builder 派生事件顺序、schema 索引和请求,无须把这些结构放进 Chat snapshot。 - 普通生成调用与压缩调用形成一条按时间排序的请求投影,以用途区分而不是放入不同集合。生效的提示词状态及其变化附着在引入它们的请求上;压缩和提示词变化都不是独立检查实体。请求编号和累计用量覆盖已加载的历史窗口,并随更早页面到达而扩展。 - 调用 schema 来自当前生效且已记录的请求头。无密钥快照 fixture(测试前置数据)有意将该目录替换为非数组 token `{{tools}}`,持久化检查边界会将其视为不可用,而不是尝试投影或虚构 schema。 - 选择记录或请求后,Trajectory 内部会打开检查器,其标签页和概述区域随实体类型变化:Markdown 消息提供渲染内容、来源字段、提供方/模型字段和层级视图;工具提供 JSON 载荷/结果和 schema 视图;请求提供选项、用量、计时和结果跳转。可滚动的概述区域默认保持滚动条滑块透明,直到悬停或 `focus-within` 时才显示,同时保留滚动条预留空间和滚动行为。图片以媒体形式渲染,而不是显示为序列化数据。 - 折叠轮次时保留其第一条记录,并用紧凑的步骤数和工具调用数替换后续所有行;折叠助手时对其工具调用后代应用相同操作。全局控件会折叠或展开这两个层级。 - 长记录表初始时将已加载尾部置于底部,只挂载视口对应的行窗口及有界的额外缓冲行。仅含请求的分隔行并入下一个具备可测高度的虚拟项,末尾分隔行则保留固定留白,因此虚拟化器不会管理零高度项。可安全用于 DOM 的语义行键与 ARIA 索引使标识不依赖挂载位置。只要已知尾部之前仍有更早历史,即使当前已加载投影低于常规行数阈值,也会立即启用虚拟化。基于稳定键的虚拟化器锚定会在向前补页和尾部追加时保留当前可见项;只有分页完成导致虚拟化停用时,才使用手动滚动高度兜底。选择、时间线聚焦、折叠、搜索和末尾跟随均按稳定的事件或工具调用标识定位,不要求对应 DOM 行已存在。初始定位完成前以及更早 Session 页面仍在等待时,明确的加载行会遮住真实记录。 -- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整分支。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 +- 移除独立的 waterfall(瀑布式事件)标签页。固定在记录表上方的 Overview 区域将所有 `startedAt` 已知的已加载记录按各自耗时投影到三条语义计时轨道。仍有更早前缀尚未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会遮住截断边缘并加载一页更早历史,而不会为未知历史虚构耗时;悬停在该控件上会隐藏普通的时间线光标。已完成的助手时间条以首个非空 token 增量为分界,用不同颜色按真实比例表示 TTFT 与解码时间;计时不完整时退化为单一助手色。悬停 500 ms 后会显示精确起止时刻、总耗时、TTFT 和解码时间,而不依赖浏览器原生 tooltip 的延迟。向左或向右拖动会提交包含边界的区间筛选:任何活动区间与所选区间任一边界重叠的记录都会保留,计时未知的记录会从聚焦后的记录表中移除,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除区间选择;右键拖动则只会平移已放大的 viewport,不会改变该选区。聚焦后,Overview 区域仍保留完整时间范围,以便在不失去方位的情况下调整或清除选择。 - 实时历史更新仅在用户已经跟随记录表末尾时保留底部位置。向上滚动会清除跟随状态,因此流式分块和新追加的记录不会打断对旧记录的检查。末尾跟随与虚拟化器测量仅响应行键和高度,而非内容标识,因此仅含文本的流式帧既不会丢弃测量缓存,也不会重复执行 DOM 滚动写入。 - 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。 @@ -53,4 +53,4 @@ Status: implemented ## 后果 -轨迹视图在保留轮次与请求定位的同时,每个视口可以显示更多有效记录。上下文 `rewrite` 与压缩保持在周边历史中的原始位置,`rewind` 则建立仅继承保留前缀的后继分支。浮动 composer 让记录表一直显示到视口边缘,同时不会遮住最后几行,也不会隐藏横向控件。主记录表省略 token 用量和耗时,让内容获得可用宽度;局部检查器展示这些数据以及完整载荷、提供方/模型字段、来源字段、schema 和请求计时。Overview 区域使用记录的开始时间、耗时与 token 边界数据,而不虚构实时流逝时间,其包含边界的聚焦行为与用户熟悉的 Chrome DevTools Network 交互一致。尾部优先分页限制初始传输工作,虚拟化限制已挂载的行元素数量,精确 ID 分发避免重新 fold 无关业务 Context,animation-frame 发布则限制流式 snapshot 频率。保留的 stage-oriented target builder 在一次发布中仍可能执行与已加载物化 Node 数量成比例的工作;本次迁移不额外承诺更强的 Trajectory 专属复杂度。针对性组件测试锁定尾部优先分页、向前补页锚定与标识保持、虚拟窗口、末尾跟随、仅含内容的流式输出不会重复写入滚动位置、计时投影、延迟展示详情、折叠、记录与区间选择、实体特定标签页和运行/错误语义。真实浏览器中的长记录表约定锁定向前补页时稳定的几何位置、有界挂载、顶部/中部/底部可达性,以及按节奏进行的流式输出中有界的滚动写入;组装后的 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/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/packages/client/runtime/tests/conversation-assembler.spec.ts b/packages/client/runtime/tests/conversation-assembler.spec.ts index 50380a20a3..6108169192 100644 --- a/packages/client/runtime/tests/conversation-assembler.spec.ts +++ b/packages/client/runtime/tests/conversation-assembler.spec.ts @@ -37,8 +37,8 @@ class TestEventDefinitions { definitions: readonly ConversationNodeDefinition[], fallback?: ConversationNodeDefinition, ) { - this.definitions = definitions.map(asChatDefinition) - this.fallback = fallback === undefined ? undefined : asChatDefinition(fallback) + this.definitions = definitions + this.fallback = fallback } entries(): readonly ConversationNodeDefinition[] { @@ -50,12 +50,6 @@ class TestEventDefinitions { } } -function asChatDefinition(definition: ConversationNodeDefinition): ConversationNodeDefinition { - return definition.buildViewNode === undefined || definition.target !== undefined - ? definition - : { ...definition, target: 'chat' } -} - class TestViewDefinitions { constructor(readonly definitions: readonly ConversationViewDefinition[]) {} @@ -118,6 +112,17 @@ function node( } } +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(( @@ -137,6 +142,7 @@ describe('ConversationNodeAssembler', () => { }, start: starts, update: updates, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -188,6 +194,7 @@ describe('ConversationNodeAssembler', () => { matchCollections.add(context.matches) return updates(context) }, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -223,6 +230,7 @@ describe('ConversationNodeAssembler', () => { }, start: starts, update: updates, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -262,6 +270,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => ({ settled: false }), update: updates, + target: 'chat', buildViewNode: context => node(context, context.state ?? { pendingStart: true }), } const assembler = new ConversationNodeAssembler( @@ -295,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( @@ -316,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(( @@ -330,6 +341,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -359,6 +371,7 @@ describe('ConversationNodeAssembler', () => { : null, start: (_context, match) => match.event.seq, update: context => context.state, + target: 'chat', buildViewNode: () => null, } const consumer: ConversationNodeDefinition = { @@ -368,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( @@ -412,6 +426,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -440,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(( @@ -454,6 +470,7 @@ describe('ConversationNodeAssembler', () => { : null, start: consumerStart, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -483,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 = { @@ -494,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 = { @@ -506,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 = { @@ -518,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( @@ -553,6 +574,7 @@ describe('ConversationNodeAssembler', () => { : null, start: starts, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -624,6 +646,7 @@ describe('ConversationNodeAssembler', () => { value: { valueSeenFromStep: stepValue ?? -1 }, } }, + target: 'chat', buildViewNode: (context) => { const location = context.start?.location if (location?.kind !== 'step') return null @@ -661,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), @@ -716,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' @@ -749,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( @@ -775,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' @@ -807,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' @@ -841,6 +869,7 @@ describe('ConversationNodeAssembler', () => { : null, start: seen, update: context => context.state, + target: 'chat', buildViewNode: context => node(context, context.state), } const assembler = new ConversationNodeAssembler( @@ -856,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) @@ -893,6 +962,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => true, update: () => false, + target: 'chat', buildViewNode: context => context.state === true ? node(context, true) : null, } const assembler = new ConversationNodeAssembler( @@ -915,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( @@ -934,6 +1005,7 @@ describe('ConversationNodeAssembler', () => { }, start: () => true, update: () => undefined as never, + target: 'chat', buildViewNode: context => node(context, context.state), } const updateAssembler = new ConversationNodeAssembler( @@ -954,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 0beaf1d5c2..5181dab926 100644 --- a/packages/client/runtime/tests/conversation-registry.spec.ts +++ b/packages/client/runtime/tests/conversation-registry.spec.ts @@ -72,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/ui-trajectory/README.i18n.yaml b/packages/client/ui-trajectory/README.i18n.yaml index 17551b0cc8..78082eb1f0 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: 75bd9ddf452634460be01e1b89cd5a1a14a1593f -README.zh.md: b5cd53dd50e43b96e2e832c96cb7e93f859c1993 +README.md: d3786b6460c5df7eaa6d24e68c80025e7fb29ae4 +README.zh.md: 5eb1451b9a3a9896d5486fcf5c8d9cf30d6159a0 diff --git a/packages/client/ui-trajectory/README.md b/packages/client/ui-trajectory/README.md index 75bd9ddf45..d3786b6460 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 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 context lineage and 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. +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. 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 b5cd53dd50..5eb1451b9a 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 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 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。 +Trajectory 渲染按轮次组织的事件记录表,其中可选择用户、助手、工具和嵌套子工具记录。较粗的分割线标示轮次边界,紧凑的行内标记标识步骤,主记录表仅保留索引、事件和内容;选择记录则会打开局部检查器,查看 token 用量、耗时、输入、输出和计时。可滚动的概述区域默认保持滚动条滑块透明,直到鼠标悬停该区域或其中包含键盘焦点时才显示,同时不改变滚动条预留的几何空间。独立运行的压缩(compaction)请求会按时间顺序显示在自己的 `Between turns` 区段中,而带编号的压缩仍位于其所属轮次内。长记录表打开时定位于当前尾部,用户到达已加载范围顶部时加载一页更早的历史,并且只挂载可见行窗口和少量额外缓冲行;仅含请求的分隔行并入下一个具备可测高度的虚拟项,语义行键和 ARIA 索引在向前补页后保持不变。选择、时间线导航、折叠、搜索和请求汇总只覆盖当前已加载的窗口。初始尾部完成定位前以及更早页面仍在等待时,记录表会用明确的加载行遮住真实记录。固定在记录表上方的 Overview 区域从左到右投影记录的真实开始时间与耗时;仍有更早记录未加载且 viewport 包含已加载时间域起点时,中性的省略号控件会标识被省略的前缀,并可加载一页更早历史,而不会为未知部分虚构耗时。助手时间条会区分记录到的 TTFT 与解码时间,悬停 500 ms 后可查看精确时刻和耗时详情。拖选一个区间会将记录表聚焦到活动区间与该闭区间有重叠的所有记录,清除选择则恢复完整的已加载记录表。滚轮手势用于缩放时间域。右键单击会清除所选区间;在已放大的 viewport 上按住右键拖动则只会平移视图,不会改变该区间。初始视图和流式更新都会停留在尾部;向上滚动会暂停跟随,因此新记录不会打断对旧记录的检查。仅含内容更新的流式帧会保持虚拟行的键和高度不变、复用测量结果,并且不会重复写入末尾滚动位置。已完成的回复会在 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 c11b607939..a0c76575ae 100644 --- a/packages/client/ui-trajectory/package.json +++ b/packages/client/ui-trajectory/package.json @@ -48,19 +48,25 @@ "diff": "^9.0.0" }, "peerDependencies": { + "@deepseek-ai/dsh-agent": "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-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", "@deepseek-ai/cordis": "workspace:^", diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 70e92f72fd..5651e620e5 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -4,13 +4,9 @@ import { useCallback, useMemo, 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 { - AssistantBlock, AssistantMessageNode, ConversationContext, ConversationSnapshot, + AssistantBlock, AssistantMessageNode, ConversationSnapshot, SnapshotStore, } from '@deepseek-ai/dsh-client-runtime/client' -import { - deriveTrajectoryContextBranches, trajectoryBranchContainsRequest, - trajectoryNodeIdentity, -} from './context-branches.ts' import { TrajectoryTable, type TrajectoryRequestNumber, @@ -190,10 +186,7 @@ export function TrajectoryView({ 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('') @@ -215,41 +208,8 @@ export function TrajectoryView({ const runningCalls = inspection.runningCalls 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 => [trajectoryNodeIdentity(node), node])) - for (const node of interruptedNodes) { - selected.set(trajectoryNodeIdentity(node), 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) @@ -345,24 +305,24 @@ 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, 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, partialTurn, partialStep, + runningCalls, requests, callSchemas, ]) const timelinePartialSignature = partialStructureSignature(partial) const timelinePartial = useMemo(() => partial === null @@ -402,9 +362,7 @@ export function TrajectoryView({ () => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches), [finalizedSearchMatches, partialSearchMatches], ) - const timelineRange = timelineSelection?.branchKey === currentBranch.key - ? timelineSelection.range - : null + const timelineRange = timelineSelection const timelineFocusIndexes = useMemo( () => timelineRange === null ? null @@ -420,11 +378,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 }) @@ -547,7 +502,6 @@ export function TrajectoryView({ />
-} - -interface MutableBranch { - id: number - key: string - contexts: ConversationContext[] - latest: ConversationContext - nodes: Map - startSeq: number - retainedSurfaceSeqs: Set -} - -/** - * Resolve the identity used while coalescing one trajectory branch. - * Synthetic tool interruptions share their closing boundary seq, so their - * call ids distinguish parallel roots without inventing false event order. - * @param node - projected conversation node. - * @returns branch-local semantic identity. - */ -export function trajectoryNodeIdentity(node: ConversationNode): string { - return node.kind === 'tool-result' - ? `tool-result\u0000${String(node.seq)}\u0000${node.callId}` - : `seq\u0000${String(node.seq)}` -} - -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 => [trajectoryNodeIdentity(node), 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(trajectoryNodeIdentity(node), 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/index.ts b/packages/client/ui-trajectory/src/client/index.ts index 1f48a4711d..a8a41f5183 100644 --- a/packages/client/ui-trajectory/src/client/index.ts +++ b/packages/client/ui-trajectory/src/client/index.ts @@ -45,9 +45,9 @@ export function apply(ctx: Context): void { return { hooks: { duration }, loadOlder: async () => { - const hadMore = session.getSnapshot().hasMore + const before = session.getSnapshot().views.get('trajectory') await session.loadOlder() - return hadMore + return session.getSnapshot().views.get('trajectory') !== before }, setActualDuration: (value) => { duration.set(value) }, } diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index d610f6979b..717b4855d7 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts @@ -9,6 +9,9 @@ import { } 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 @@ -389,6 +392,7 @@ const trajectoryTurnEndDefinition: ConversationNodeDefinition = { ...(context.state.error === undefined ? {} : { error: context.state.error }), }), } +/* jscpd:ignore-end */ /** * Register the Trajectory Assistant lifecycle. diff --git a/packages/client/ui-trajectory/src/client/trajectory-contract.ts b/packages/client/ui-trajectory/src/client/trajectory-contract.ts index e261eeb9fc..3e877a7969 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-contract.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-contract.ts @@ -1,5 +1,5 @@ import type { - AssistantMessageNode, ConversationContext, ConversationLocation, ConversationNode, + AssistantMessageNode, ConversationLocation, ConversationNode, ConversationPromptSnapshot, ConversationViewNode, PartialAssistant, RequestPromptChange, RequestView, RunningToolCall, ToolCallBlock, } from '@deepseek-ai/dsh-client-runtime/client' @@ -59,10 +59,8 @@ export interface TrajectoryConversationViewNode extends ConversationViewNode { /** Stage-oriented Trajectory data assembled from registered business Contexts. */ export interface TrajectorySnapshot { readonly eventNodes: readonly ConversationNode[] - readonly contexts: readonly ConversationContext[] readonly requests: readonly RequestView[] readonly callSchemas: ReadonlyMap - readonly interruptedNodes: readonly ConversationNode[] readonly partial: PartialAssistant | null readonly runningCalls: readonly RunningToolCall[] } diff --git a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts index 8c9c7d6489..639b1ad3ea 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-definition-common.ts @@ -1,22 +1,8 @@ -import type { - ConversationLocation, ConversationNodeContext, -} from '@deepseek-ai/dsh-client-runtime/client' +import type { ConversationNodeContext } from '@deepseek-ai/dsh-client-runtime/client' import type { TrajectoryContribution, TrajectoryConversationViewNode, } from './trajectory-contract.ts' -/** - * Resolve the best loaded Location for one target-local Context. - * - * @param context - Context whose loaded matches provide the Location. - * @returns The start Location, first-match Location, or unresolved fallback. - */ -export function trajectoryContextLocation( - context: ConversationNodeContext, -): ConversationLocation { - return context.start?.location ?? context.matches[0]?.location ?? { kind: 'unresolved' } -} - /** * Wrap one contribution in the Engine-owned target envelope. * diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts index a35b6080db..c8861c85a7 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -9,6 +9,9 @@ import { 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 } @@ -106,6 +109,7 @@ const trajectoryMessageDefinition: ConversationNodeDefinition = { ? null : trajectoryNode(context, context.state.seq, { kind: 'node', node: context.state }), } +/* jscpd:ignore-end */ /** * Register Trajectory-owned inbox classification and message records. diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index 2f4c697275..a7f2de6be4 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -10,17 +10,14 @@ import type { } from './trajectory-contract.ts' const EMPTY_LIST: readonly never[] = [] -const EMPTY_CONTEXTS = [{ id: 0, nodes: EMPTY_LIST }] 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, - contexts: EMPTY_CONTEXTS, requests: EMPTY_LIST, callSchemas: new Map(), - interruptedNodes: EMPTY_LIST, partial: null, runningCalls: EMPTY_LIST, } @@ -249,10 +246,8 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< const eventNodes = finalized return { eventNodes, - contexts: [{ id: 0, nodes: eventNodes }], requests, callSchemas, - interruptedNodes: EMPTY_LIST, partial, runningCalls, } diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts index c72c6c8709..201ac15d72 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts @@ -6,6 +6,9 @@ import type { 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 { @@ -109,11 +112,26 @@ function childResult( function acceptsEdge(state: ToolState, parent: string, child: string): boolean { if (parent === child || state.parents.has(child)) return false let cursor: string | undefined = parent - for (let depth = 0; cursor !== undefined && depth <= MAX_DEPTH; depth++) { - if (cursor === child) return false + 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) } - return cursor === undefined + 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 { @@ -243,6 +261,7 @@ const trajectoryToolDefinition: ConversationNodeDefinition = { return trajectoryNode(context, anchorSeq, { kind: 'tool', root }) }, } +/* jscpd:ignore-end */ /** * Register the Trajectory Tool lifecycle. diff --git a/packages/client/ui-trajectory/tests/client-bundle.spec.ts b/packages/client/ui-trajectory/tests/client-bundle.spec.ts index 902363b719..9590f28b5e 100644 --- a/packages/client/ui-trajectory/tests/client-bundle.spec.ts +++ b/packages/client/ui-trajectory/tests/client-bundle.spec.ts @@ -84,9 +84,15 @@ describe('tsdown client artifact', () => { ctx.provide('sessions', { binding: () => undefined }) 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 9885e9a4f9..0000000000 --- a/packages/client/ui-trajectory/tests/context-branches.spec.ts +++ /dev/null @@ -1,129 +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 interruptedTool(callId: string): ConversationNode { - return { - kind: 'tool-result', - seq: 19.2, - time: 20, - callId, - call: { name: 'parallel', argsRaw: '{}' }, - callTime: 10, - content: [], - isError: true, - error: { name: 'Interrupted', code: 'interrupted' }, - callView: null, - resultView: null, - subCalls: [], - } -} - -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) - }) - - it('retains parallel tool interruptions that share one closing boundary', () => { - const branch = deriveTrajectoryContextBranches([{ - id: 0, - nodes: [interruptedTool('call-a'), interruptedTool('call-b')], - }])[0] - - expect(branch?.nodes.map(node => node.kind === 'tool-result' ? node.callId : undefined)) - .toEqual(['call-a', 'call-b']) - }) -}) 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..f61f90c02c --- /dev/null +++ b/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts @@ -0,0 +1,276 @@ +import type { Context } from '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 current = snapshot(assembler([ + at(1, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 0, inserted: [{ id: 'm1' }], + }), + at(2, 'agent/inbox/spliced', { + target: 'next-step', start: 0, removedCount: 1, inserted: [], + }), + at(3, 'user/message', { + id: 'm1', + role: 'user', + content: [{ type: 'text', text: 'steer here' }], + source: { kind: 'user' }, + }), + at(4, 'turn/start', { turn: 1 }), + at(5, 'request/header', { + reason: 'initial', + header: { + config: { provider: 'test', model: 'test' }, + system: 'system prompt', + tools: [], + }, + }), + at(6, 'step/start', { turn: 1, step: 1 }), + at(7, 'assistant/message', { + turn: 1, + step: 1, + message: assistantMessage('assistant-1', 'first'), + }), + at(8, 'step/end', { turn: 1, step: 1 }), + at(9, 'step/start', { turn: 1, step: 2 }), + at(10, 'assistant/message', { + turn: 1, + step: 2, + message: assistantMessage('assistant-2', 'second'), + }), + ])) + + expect(current.eventNodes.find(node => node.seq === 3)?.kind).toBe('steering') + 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/views.spec.tsx b/packages/client/ui-trajectory/tests/views.spec.tsx index 2439fb2de5..88c7f41ccf 100644 --- a/packages/client/ui-trajectory/tests/views.spec.tsx +++ b/packages/client/ui-trajectory/tests/views.spec.tsx @@ -75,10 +75,8 @@ function historySnapshot( ): ConversationSnapshot { const trajectory: TrajectorySnapshot = { eventNodes: nodes, - contexts: [{ id: 0, nodes }], requests: [], callSchemas: new Map(), - interruptedNodes: [], partial: null, runningCalls: [], ...inspection, @@ -191,7 +189,7 @@ async function bench(snapshot = historySnapshot(NODES)) { { name: 'conversation.view', id: 'chat', order: 0, label: 'Chat' } as never, chatBody as never) const fiber = ctx.plugin({ inject: [...inject], apply }) await fiber.await() - return { ctx, slots, fiber, loadOlder } + return { ctx, slots, fiber, loadOlder, sessionStore } } /** Tab projection twin of apply's viewTabs (the render-side consumption path). */ @@ -294,8 +292,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 () => { @@ -315,6 +321,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', () => { @@ -1083,7 +1106,7 @@ describe('timeline projection', () => { }) }) -describe('TrajectoryView branches', () => { +describe('TrajectoryView state', () => { it('persists the duration preference through the runtime snapshot-store seam', () => { const firstDuration = createTrajectoryDurationStore() const commonProps = { @@ -1116,109 +1139,6 @@ 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(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(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 = { kind: 'user', seq: 1, time: 1_000, @@ -1249,46 +1169,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(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..5feffced67 100644 --- a/packages/client/ui-trajectory/tsconfig.json +++ b/packages/client/ui-trajectory/tsconfig.json @@ -20,6 +20,15 @@ { "path": "../runtime" }, + { + "path": "../../core/agent" + }, + { + "path": "../../core/tools" + }, + { + "path": "../../compact/compact" + }, { "path": "../../support/invariants" } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 40f2643417..771706fb80 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -2813,6 +2813,9 @@ importers: '@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 @@ -2825,9 +2828,15 @@ 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 From 556b11ef56438979f4d8e3ba70cbe8200a325239 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 21:42:12 +0800 Subject: [PATCH 064/113] docs: refresh module graph --- docs/module-graph.i18n.yaml | 4 ++-- docs/module-graph.md | 11 +++++++---- docs/module-graph.zh.md | 11 +++++++---- 3 files changed, 16 insertions(+), 10 deletions(-) diff --git a/docs/module-graph.i18n.yaml b/docs/module-graph.i18n.yaml index 954f038557..1f864cf46d 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: 3efb73d075f3d5d7a8bae990fc2f524dc710bcb7 -module-graph.zh.md: df3b9b38497893471b2613c0c95da409dda0262b +module-graph.md: 2218d79e28e835ab96abce96eaf92bbae25e2182 +module-graph.zh.md: 276b70d69c2898d74ac6897e398b02a8944fd503 diff --git a/docs/module-graph.md b/docs/module-graph.md index 3efb73d075..2218d79e28 100644 --- a/docs/module-graph.md +++ b/docs/module-graph.md @@ -399,9 +399,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 @@ -893,6 +890,12 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_client_ui_trajectory --> pkg_agent + 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 @@ -1295,7 +1298,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) | @@ -1400,6 +1402,7 @@ 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-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`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) | diff --git a/docs/module-graph.zh.md b/docs/module-graph.zh.md index df3b9b3849..276b70d69c 100644 --- a/docs/module-graph.zh.md +++ b/docs/module-graph.zh.md @@ -401,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 @@ -895,6 +892,12 @@ flowchart TD pkg_llm_replay --> pkg_invariants pkg_llm_replay --> pkg_llm pkg_llm_replay --> pkg_session + pkg_client_ui_trajectory --> pkg_agent + 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 @@ -1297,7 +1300,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) | @@ -1402,6 +1404,7 @@ 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-ui-trajectory`](../packages/client/ui-trajectory) | `client` | [`agent`](../packages/core/agent), [`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) | From cc25bceec8c9a6292fe598948f956239fb1ab8cc Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:13:21 +0800 Subject: [PATCH 065/113] fix(ui-trajectory): simplify terminal contribution branch --- .../src/client/trajectory-snapshot-builder.ts | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index a7f2de6be4..d10979f75f 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -230,13 +230,11 @@ export class TrajectorySnapshotBuilder implements ConversationViewBuilder< boundaries.push({ seq: data.seq, time: data.time }) continue } - if (data.kind === 'turn-end') { - turnEndings.push({ - turn: data.turn, - time: data.time, - ...(data.error === undefined ? {} : { error: data.error }), - }) - } + turnEndings.push({ + turn: data.turn, + time: data.time, + ...(data.error === undefined ? {} : { error: data.error }), + }) } requests.sort((left, right) => left.startSeq - right.startSeq) From 58ea69f64f84bc92d05da3aeec8f41b726173e79 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 22:23:14 +0800 Subject: [PATCH 066/113] build(ui-trajectory): use rescoped cordis package --- .../ui-trajectory/src/client/trajectory-assistant-definition.ts | 2 +- .../src/client/trajectory-compaction-definition.ts | 2 +- .../ui-trajectory/src/client/trajectory-message-definitions.ts | 2 +- .../src/client/trajectory-request-header-definition.ts | 2 +- .../ui-trajectory/src/client/trajectory-snapshot-builder.ts | 2 +- .../ui-trajectory/src/client/trajectory-tool-definition.ts | 2 +- .../client/ui-trajectory/tests/conversation-definitions.spec.ts | 2 +- 7 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts index 717b4855d7..16b61f6d53 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-assistant-definition.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, PartialAssistant, RequestView, diff --git a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts index de6d2af21b..a822e4e5bb 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-compaction-definition.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeDefinition, RequestView, } from '@deepseek-ai/dsh-client-runtime/client' diff --git a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts index c8861c85a7..4139a318db 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-message-definitions.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ContextMessageNode, ConversationNodeDefinition, ConversationPreviousContext, SteeringMessageNode, UserMessageNode, 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 index 4d8a0c9006..20a4d437e9 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-request-header-definition.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { ConversationMatch, ConversationNodeDefinition, ConversationPromptSnapshot, RequestPromptChange, diff --git a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts index d10979f75f..dcc5f2edbc 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-snapshot-builder.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import type { AssistantMessageNode, ConversationNode, ConversationPromptSnapshot, ConversationViewBuilder, ConversationViewDefinition, RequestView, diff --git a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts index 201ac15d72..7e069dd912 100644 --- a/packages/client/ui-trajectory/src/client/trajectory-tool-definition.ts +++ b/packages/client/ui-trajectory/src/client/trajectory-tool-definition.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, diff --git a/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts b/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts index f61f90c02c..631d283f21 100644 --- a/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts +++ b/packages/client/ui-trajectory/tests/conversation-definitions.spec.ts @@ -1,4 +1,4 @@ -import type { Context } from 'cordis' +import type { Context } from '@deepseek-ai/cordis' import { describe, expect, it } from 'vitest' import type { ConversationEventInput, ConversationNodeDefinition, ConversationViewDefinition, From e27dba845760211d7f8280cc02134e531dc778b7 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:51:18 +0800 Subject: [PATCH 067/113] perf(ui-trajectory): defer trajectory text processing --- ...lient-conversation-node-assembly.i18n.yaml | 4 +- ...08-09-client-conversation-node-assembly.md | 2 + ...09-client-conversation-node-assembly.zh.md | 2 + packages/client/ui-primitives/src/Tooltip.tsx | 13 +- .../ui-primitives/tests/tooltip.spec.tsx | 21 +++ .../src/client/TrajectoryTable.tsx | 134 +++++++++++++----- .../src/client/TrajectoryTimeline.tsx | 2 +- .../src/client/TrajectoryView.tsx | 125 +++++++--------- .../client/ui-trajectory/src/client/layout.ts | 121 +++++++++------- .../src/client/trajectory-preview.ts | 20 +++ .../src/client/trajectory-record.ts | 6 +- .../src/client/trajectory-search-index.ts | 133 +++++++++++++++++ 12 files changed, 415 insertions(+), 168 deletions(-) create mode 100644 packages/client/ui-trajectory/src/client/trajectory-preview.ts create mode 100644 packages/client/ui-trajectory/src/client/trajectory-search-index.ts 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 1dec922826..3d569b7ce2 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: 1d5fd20bfa8c3b370f736937d54a668ca7f19ca3 -2026-08-09-client-conversation-node-assembly.zh.md: 6f0acc448950cdedcb249ada8cfd931a21765b3e +2026-08-09-client-conversation-node-assembly.md: 3b02fde8b5c8da0c7086a2de65a5ae8eea8b2526 +2026-08-09-client-conversation-node-assembly.zh.md: 2ddb14c35b3ac5aeba5b00e7d56b3a4e69a97adb 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 1d5fd20bfa..3b02fde8b5 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 @@ -330,6 +330,8 @@ The concrete Tool renderer remains governed by the [`ui-tool ownership decision` 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. +Trajectory stage/layout processing retains raw summary sources and structural data without parsing Markdown. A stable Record presentation in the Table memoizes each one-line summary by content and shares the result across body text, title, and aria-label; Detail renders only the selected record. Timeline timing labels invoke their formatters only after the delayed Tooltip opens. Search owns an independent per-view `TrajectorySearchIndex` keyed by stable Record identity with each source signature and normalized text. The initial window is indexed immediately, and a three-second throttle commits later new or changed Records in batches. Queries read only the latest committed index version, so a prepended page enters results atomically with the next batch; neither prepend nor append reparses unchanged historical Markdown. Display caching and search indexing do not share lifecycles. + ## Runtime and render path ```text 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 6f0acc4489..2ddb14c35b 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 @@ -330,6 +330,8 @@ Assistant streaming 到 final、Tool running 到 settled 只更新同一个 Seat 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 契约。 +Trajectory 的 stage/layout 只保留原始摘要来源和结构数据,不解析 Markdown。Table 的稳定 Record presentation 按内容 memo 单行摘要,并把同一结果用于正文、title 与 aria-label;Detail 只渲染当前选中记录。Timeline 的时序标签只在延迟 Tooltip 实际打开后执行格式化。搜索拥有独立的 per-view `TrajectorySearchIndex`,按稳定 Record identity 保存来源签名和标准化文本;初始窗口立即建立索引,后续新增或变化的 Record 由三秒 throttle 批量提交。查询只读取最近一次提交的索引版本,分页的新一页随下一批一次性进入结果;prepend 与 append 都不会重复解析未变化的历史 Markdown。展示缓存与搜索索引互不借用生命周期。 + ## Runtime and render path ```text diff --git a/packages/client/ui-primitives/src/Tooltip.tsx b/packages/client/ui-primitives/src/Tooltip.tsx index 449d4fe717..c1c1d1c5dc 100644 --- a/packages/client/ui-primitives/src/Tooltip.tsx +++ b/packages/client/ui-primitives/src/Tooltip.tsx @@ -24,9 +24,11 @@ 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 @@ -34,7 +36,7 @@ interface AnchorProps { * @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, children }: { label: TooltipLabel; side?: TooltipSide; delayMs?: number; disabled?: boolean; 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. @@ -46,6 +48,9 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, }, [childRef]) const [pos, setPos] = useState<{ x: number; y: number } | null>(null) const bubble = useRef(null) + const resolvedLabel = pos === null + ? null + : typeof label === 'function' ? label() : label // 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 @@ -67,7 +72,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, clamp() window.addEventListener('resize', clamp) return () => { window.removeEventListener('resize', clamp) } - }, [label, pos]) + }, [pos, resolvedLabel]) 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). @@ -128,7 +133,7 @@ export function Tooltip({ label, side = 'right', delayMs = 0, disabled = false, })} {pos !== null && ( - {label} + {resolvedLabel} )} diff --git a/packages/client/ui-primitives/tests/tooltip.spec.tsx b/packages/client/ui-primitives/tests/tooltip.spec.tsx index 72b33ce12c..b355b56f68 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 { diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 9cef2dccfb..67ec1cd2d6 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 @@ -903,6 +904,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 +918,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 +944,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, @@ -2131,15 +2208,12 @@ 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(' ') + {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' @@ -2169,7 +2243,6 @@ export function TrajectoryTable({ : activeTurn === record.turn return ( - - {toolCallOnly - ? (tool call only) - : toolCallText === undefined - ? listDisplayText || '—' - : ( - <> - - {toolCallText.name || '—'} - - {toolCallText.args !== undefined && ( - - {toolCallText.args} - - )} - - )} + + - {record.cell.result !== undefined && ( + {resultText !== undefined && ( - - {record.cell.result} + {resultText} )} @@ -2387,7 +2449,9 @@ export function TrajectoryTable({ ) - })} + }} + + ))} {virtualBottom > 0 && ( timelineTooltipLabel(span.kind, detail)} side="bottom" delayMs={TIMELINE_TOOLTIP_DELAY_MS} > diff --git a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx index 5651e620e5..2b2e50fc65 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryView.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryView.tsx @@ -1,6 +1,6 @@ /** Trajectory view: compact summary over a turn-aware event ledger. */ -import { useCallback, useMemo, useState } from 'react' +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 { @@ -24,11 +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 @@ -115,70 +117,6 @@ 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({ useSession, useDuration, loadOlder, setActualDuration, inspect, onInspectDone, @@ -190,6 +128,10 @@ export function TrajectoryView({ const actualDuration = useDuration(value => value) const [actualTime, setActualTime] = useState(false) const [searchQuery, setSearchQuery] = useState('') + 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 @@ -340,28 +282,59 @@ 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 searchMatchIndexes = useMemo( - () => mergeSearchMatches(finalizedSearchMatches, partialSearchMatches), - [finalizedSearchMatches, partialSearchMatches], + const searchMatchRecordIds = useMemo( + () => searchIndex.search(searchQuery), + [searchIndex, searchIndexRevision, searchQuery], ) + 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 diff --git a/packages/client/ui-trajectory/src/client/layout.ts b/packages/client/ui-trajectory/src/client/layout.ts index 0d7a8c9e07..3a118aadfb 100644 --- a/packages/client/ui-trajectory/src/client/layout.ts +++ b/packages/client/ui-trajectory/src/client/layout.ts @@ -12,7 +12,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, @@ -70,9 +69,6 @@ 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' } @@ -110,10 +106,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), @@ -293,7 +298,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 ? {} @@ -377,6 +385,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 +395,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 +449,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 +659,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 +693,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 +701,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, } : {}), @@ -919,6 +932,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 +941,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 +953,7 @@ function expandSubCalls( ? { outputDetail: detailResult(sub), outputBlocks: sub.content.map(block => sourceBlock(block)), - result: summarizeResult(sub), + ...resultPreview, isError: sub.isError, } : {}), @@ -958,22 +974,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 +1042,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/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..38101f8233 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. */ 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..889fbff7e6 --- /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 + } +} From 2695f31edb633a2a59ecc8fbd27162184137a219 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:01:14 +0800 Subject: [PATCH 068/113] test(ui-trajectory): assert deferred preview sources --- .../ui-trajectory/tests/layout.spec.tsx | 43 +++++++++++++------ 1 file changed, 30 insertions(+), 13 deletions(-) diff --git a/packages/client/ui-trajectory/tests/layout.spec.tsx b/packages/client/ui-trajectory/tests/layout.spec.tsx index 4c49b07907..d04c95b46d 100644 --- a/packages/client/ui-trajectory/tests/layout.spec.tsx +++ b/packages/client/ui-trajectory/tests/layout.spec.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,14 @@ 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 standalone compaction chronologically in its own between-turn section', () => { @@ -263,7 +276,8 @@ describe('deriveTrajectoryLayout', () => { cells: [{ kind: 'compacted', sourceSeq: 3, - text: 'standalone summary', + text: '', + previewMarkdown: 'standalone summary', }], }]) }) @@ -279,7 +293,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 +310,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 +344,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 +407,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 +420,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', () => { From 98a4cc6569fc66c8d56e94175594102963235447 Mon Sep 17 00:00:00 2001 From: imccyu <276526105+imccyu@users.noreply.github.com> Date: Tue, 11 Aug 2026 00:04:51 +0800 Subject: [PATCH 069/113] style(ui-trajectory): align presentation render tree --- .../src/client/TrajectoryTable.tsx | 432 +++++++++--------- .../client/ui-trajectory/src/client/layout.ts | 4 +- .../src/client/trajectory-search-index.ts | 14 +- 3 files changed, 225 insertions(+), 225 deletions(-) diff --git a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx index 67ec1cd2d6..a1db84f50c 100644 --- a/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx +++ b/packages/client/ui-trajectory/src/client/TrajectoryTable.tsx @@ -2214,241 +2214,241 @@ export function TrajectoryTable({ cell={record.cell} > {({ displayText, listDisplayText, resultText, toolCallOnly, toolCallText }) => { - const isCollapsedSummary = record.collapsedSummary !== undefined - const isRequestOnly = record.cell.requestOnly === true - const isInitialSystem = record.cell.kind === 'system' + 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 request = record.groupStart && !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(requestKey(record.turn, record.group)) + : 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 && ( -